From 839c7ee5c8239ffd578ce45ff7cd451c967833ee Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Fri, 21 Apr 2023 23:29:14 +0100 Subject: [PATCH] [FEAT] Complete basic functionality including multiplayer and styling --- src/main/java/module-info.java | 1 + .../ac/soton/comp1206/component/Chat.java | 171 ++++++++++ .../soton/comp1206/component/Leaderboard.java | 65 ++++ .../soton/comp1206/component/ScoresList.java | 35 +- .../comp1206/{ui => component}/StatsMenu.java | 9 +- .../comp1206/event/GameFailureListener.java | 15 + .../mgrove/ac/soton/comp1206/game/Game.java | 37 ++- .../mgrove/ac/soton/comp1206/game/Grid.java | 3 + .../soton/comp1206/game/MultiplayerGame.java | 231 +++++++++++++ .../soton/comp1206/scene/ChallengeScene.java | 40 ++- .../comp1206/scene/InstructionsScene.java | 28 +- .../ac/soton/comp1206/scene/LoadingScene.java | 81 +++++ .../ac/soton/comp1206/scene/LobbyScene.java | 304 ++++++++++++++++++ .../ac/soton/comp1206/scene/MenuScene.java | 73 ++++- .../comp1206/scene/MultiplayerScene.java | 85 +++++ .../ac/soton/comp1206/scene/ScoresScene.java | 223 +++++++++++-- .../ac/soton/comp1206/ui/GameWindow.java | 56 +++- .../ac/soton/comp1206/util/Multimedia.java | 5 +- src/main/resources/style/game.css | 16 +- 19 files changed, 1367 insertions(+), 111 deletions(-) create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/component/Chat.java create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/component/Leaderboard.java rename src/main/java/uk/mgrove/ac/soton/comp1206/{ui => component}/StatsMenu.java (78%) create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/event/GameFailureListener.java create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/game/MultiplayerGame.java create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/scene/LoadingScene.java create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/scene/LobbyScene.java create mode 100644 src/main/java/uk/mgrove/ac/soton/comp1206/scene/MultiplayerScene.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index e4fa93e..d57966e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -13,4 +13,5 @@ module uk.mgrove.ac.soton.comp1206 { exports uk.mgrove.ac.soton.comp1206.event; exports uk.mgrove.ac.soton.comp1206.component; exports uk.mgrove.ac.soton.comp1206.game; + opens uk.mgrove.ac.soton.comp1206.component to javafx.fxml; } \ No newline at end of file diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/component/Chat.java b/src/main/java/uk/mgrove/ac/soton/comp1206/component/Chat.java new file mode 100644 index 0000000..15f5ee0 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/component/Chat.java @@ -0,0 +1,171 @@ +package uk.mgrove.ac.soton.comp1206.component; + +import javafx.application.Platform; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import uk.mgrove.ac.soton.comp1206.network.Communicator; +import uk.mgrove.ac.soton.comp1206.util.Multimedia; + +public class Chat extends VBox { + + /** + * Logger + */ + private static final Logger logger = LogManager.getLogger(Chat.class); + + /** + * Scroll pane to hold messages + */ + private final ScrollPane messagesContainer = new ScrollPane(); + + /** + * Container for messages in channel + */ + private final VBox messages = new VBox(); + + /** + * Text field for messages to send to channels + */ + private final TextField newMessageText = new TextField(); + + /** + * Button to send message + */ + private final Text sendMessage = new Text("Send"); + + /** + * User's current nickname + */ + private String nickname = ""; + + /** + * The communicator to use + */ + private Communicator communicator; + + /** + * Whether chat should scroll to bottom next time layout updates + */ + private boolean scrollToBottom; + + /** + * Whether the last chat message is from the local user + */ + private boolean lastMessageFromMe; + + /** + * Initialise the chat container with a communicator + * @param communicator the communicator to use + */ + public Chat(Communicator communicator, boolean compact) { + this.communicator = communicator; + + newMessageText.setPromptText("Type a message..."); + newMessageText.setOnAction(this::sendMessage); + + messages.setSpacing(4); + messagesContainer.setContent(messages); + messagesContainer.setFitToWidth(true); + messagesContainer.setFitToHeight(true); + messagesContainer.setPrefHeight(320); + messagesContainer.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + messagesContainer.setVvalue(1.0); + messagesContainer.getStyleClass().add("scroller"); + + var newMessageContainer = new HBox(); + newMessageContainer.setAlignment(Pos.CENTER_LEFT); + newMessageContainer.setSpacing(8); + sendMessage.getStyleClass().add("channelItem"); + sendMessage.setOnMouseClicked(this::sendMessage); + + if (compact) newMessageText.setPrefWidth(140); + else newMessageText.setPrefWidth(400); + logger.info("New message text has pref width of: {}", newMessageText.getPrefWidth()); + newMessageContainer.getChildren().addAll(newMessageText, sendMessage); + + getChildren().addAll(messagesContainer, newMessageContainer); + + communicator.addListener((message) -> { + if (message.matches("^MSG .+:.+$")) { + var data = message.replaceFirst("MSG ", "").split(":"); + logger.info("Received message: {} from: {}", data[1], data[0]); + if (messagesContainer.getVvalue() == 1.0f) scrollToBottom = true; + + Platform.runLater(() -> { + var user = new Text(data[0] + " "); + user.getStyleClass().add("myname"); + var messageText = new Text(data[1]); + var messageContainer = new TextFlow(user, messageText); + messages.getChildren().add(messageContainer); + }); + + if (!lastMessageFromMe) Multimedia.playAudio("sounds/message.wav"); + else lastMessageFromMe = false; + } + }); + } + + /** + * Send a message to a channel + * + * @param event triggering action event + */ + private void sendMessage(Event event) { + if (!newMessageText.getText().isBlank()) { + if (newMessageText.getText().startsWith("/nick ")) { + var newNickname = newMessageText.getText().replaceFirst("/nick ", ""); + logger.info("Setting new nickname: {}", newNickname); + communicator.send("NICK " + newNickname); + } else { + logger.info("Sending message: {}", newMessageText.getText()); + communicator.send("MSG " + newMessageText.getText()); + lastMessageFromMe = true; + } + newMessageText.clear(); + } + } + + /** + * Scroll to bottom of messages list. + */ + public void scrollToBottom() { + if (!scrollToBottom) return; + logger.info("Scrolling to bottom of chat"); + messagesContainer.setVvalue(1.0); + scrollToBottom = false; + } + + /** + * Focus the message input field + */ + public void focusInputField() { + newMessageText.requestFocus(); + } + + /** + * Set whether chat elements should be focus traversable + * @param traversable whether the elements should be focus traversable + */ + public void setChatFocusTraversable(boolean traversable) { + newMessageText.setFocusTraversable(traversable); + sendMessage.setFocusTraversable(traversable); + } + + /** + * Clear the message history + */ + public void clearMessages() { + messages.getChildren().clear(); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/component/Leaderboard.java b/src/main/java/uk/mgrove/ac/soton/comp1206/component/Leaderboard.java new file mode 100644 index 0000000..133812e --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/component/Leaderboard.java @@ -0,0 +1,65 @@ +package uk.mgrove.ac.soton.comp1206.component; + +import javafx.application.Platform; +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.scene.layout.HBox; +import javafx.scene.text.Text; +import javafx.util.Pair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Map; + +/** + * Component to display online leaderboard in multiplayer games + */ +public class Leaderboard extends ScoresList { + + /** + * Logger + */ + private static final Logger logger = LogManager.getLogger(Leaderboard.class); + + /** + * Scores - pair of username with pair of score and lives + */ + private final SimpleListProperty>> scores = new SimpleListProperty<>(FXCollections.observableArrayList()); + + /** + * Initialise the leaderboard + */ + public Leaderboard(String titleText) { + super(); + title = new Text(titleText); + title.getStyleClass().add("heading"); + getChildren().add(title); + + reveal(); + scores.addListener((ListChangeListener>>) change -> Platform.runLater(() -> { + logger.info("Detected change in scores list: {}", change); + getChildren().clear(); + getChildren().add(title); + for (var pair : scores.get()) { + var nameField = new Text(pair.getKey()); + if (pair.getValue().getValue() == -1) nameField.setStrikethrough(true); + else nameField.setText(nameField.getText() + " (" + pair.getValue().getValue().toString() + ")"); + var scoreField = new Text(pair.getValue().getKey().toString()); + scoreField.getStyleClass().add("myscore"); + var individualScoreContainer = new HBox(nameField, scoreField); + individualScoreContainer.setSpacing(8); + getChildren().add(individualScoreContainer); + } + })); + } + + /** + * Bind scores to external property + * @param scores external property to bind to + */ + public void bindLeaderboardScores(SimpleListProperty>> scores) { + this.scores.bindBidirectional(scores); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/component/ScoresList.java b/src/main/java/uk/mgrove/ac/soton/comp1206/component/ScoresList.java index 42634cc..1dafec3 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/component/ScoresList.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/component/ScoresList.java @@ -1,6 +1,7 @@ package uk.mgrove.ac.soton.comp1206.component; import javafx.animation.FadeTransition; +import javafx.application.Platform; import javafx.beans.property.SimpleListProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; @@ -26,28 +27,42 @@ public class ScoresList extends VBox { /** * Scores */ - private final SimpleListProperty> scores; + protected final SimpleListProperty> scores = new SimpleListProperty<>(FXCollections.observableArrayList()); + + /** + * Title for scores list + */ + protected Text title; /** * Initialise the scores list */ - public ScoresList() { + public ScoresList(String titleText) { logger.info("Building scores list"); - ObservableList> observableScoresList = FXCollections.observableArrayList(); - scores = new SimpleListProperty<>(observableScoresList); - setOpacity(0); - scores.addListener((ListChangeListener>) change -> { + title = new Text(titleText); + title.getStyleClass().add("title"); + getChildren().add(title); + + scores.addListener((ListChangeListener>) change -> Platform.runLater(() -> { logger.info("Detected change in scores list: {}", change); getChildren().clear(); + getChildren().add(title); for (var pair : scores.get()) { - var score = new HBox(); - score.getChildren().addAll(new Text(pair.getKey()), new Text(pair.getValue().toString())); - getChildren().add(score); + var individualScoreContainer = new HBox(); + individualScoreContainer.getChildren().addAll(new Text(pair.getKey()), new Text(pair.getValue().toString())); + getChildren().add(individualScoreContainer); } - }); + })); + } + + /** + * Empty constructor + */ + public ScoresList() { + } /** diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/StatsMenu.java b/src/main/java/uk/mgrove/ac/soton/comp1206/component/StatsMenu.java similarity index 78% rename from src/main/java/uk/mgrove/ac/soton/comp1206/ui/StatsMenu.java rename to src/main/java/uk/mgrove/ac/soton/comp1206/component/StatsMenu.java index 8a4089b..580fe8a 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/StatsMenu.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/component/StatsMenu.java @@ -1,4 +1,4 @@ -package uk.mgrove.ac.soton.comp1206.ui; +package uk.mgrove.ac.soton.comp1206.component; import javafx.beans.property.IntegerProperty; import javafx.scene.layout.VBox; @@ -28,19 +28,24 @@ public class StatsMenu extends VBox { Text score1 = new Text(); score1.textProperty().bind(score.asString()); + score1.getStyleClass().add("score"); Text level1 = new Text(); level1.textProperty().bind(level.asString()); + level1.getStyleClass().add("level"); Text lives1 = new Text(); lives1.textProperty().bind(lives.asString()); + lives1.getStyleClass().add("lives"); Text multiplier1 = new Text(); multiplier1.textProperty().bind(multiplier.asString()); + multiplier1.getStyleClass().add("level"); var scoreHeader = new Text("Score"); var levelHeader = new Text("Level"); var livesHeader = new Text("Lives"); var multiplierHeader = new Text("Multiplier"); - getChildren().addAll(scoreHeader, score1,levelHeader, level1,livesHeader, lives1,multiplierHeader, multiplier1); + getChildren().addAll(new VBox(scoreHeader, score1),new VBox(livesHeader, lives1),new VBox(levelHeader, level1),new VBox(multiplierHeader, multiplier1)); + setSpacing(8); } } diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/event/GameFailureListener.java b/src/main/java/uk/mgrove/ac/soton/comp1206/event/GameFailureListener.java new file mode 100644 index 0000000..42e812a --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/event/GameFailureListener.java @@ -0,0 +1,15 @@ +package uk.mgrove.ac.soton.comp1206.event; + +import uk.mgrove.ac.soton.comp1206.component.GameBlock; + +/** + * Listener for when multiplayer games fail - e.g. due to network errors + */ +public interface GameFailureListener { + + /** + * Handle a game failure + */ + public void gameFail(); + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/game/Game.java b/src/main/java/uk/mgrove/ac/soton/comp1206/game/Game.java index 0f5350e..6210b57 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/game/Game.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/game/Game.java @@ -3,14 +3,13 @@ package uk.mgrove.ac.soton.comp1206.game; import javafx.application.Platform; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.util.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import uk.mgrove.ac.soton.comp1206.component.GameBlock; import uk.mgrove.ac.soton.comp1206.component.GameBlockCoordinate; -import uk.mgrove.ac.soton.comp1206.event.GameLoopListener; -import uk.mgrove.ac.soton.comp1206.event.LineClearedListener; -import uk.mgrove.ac.soton.comp1206.event.NextPieceListener; -import uk.mgrove.ac.soton.comp1206.event.ShowScoresSceneListener; +import uk.mgrove.ac.soton.comp1206.event.*; import uk.mgrove.ac.soton.comp1206.util.Multimedia; import java.util.*; @@ -49,12 +48,12 @@ public class Game { /** * Current game piece player is using */ - private GamePiece currentPiece; + protected GamePiece currentPiece; /** * Next game piece for player to use */ - private GamePiece followingPiece; + protected GamePiece followingPiece; /** * Player's current score @@ -76,7 +75,7 @@ public class Game { /** * Listener for when new piece is received by the game */ - private NextPieceListener nextPieceListener; + protected NextPieceListener nextPieceListener; /** * Listener for when lines of blocks are cleared @@ -128,7 +127,7 @@ public class Game { currentPiece = followingPiece; followingPiece = spawnPiece(); if (nextPieceListener != null) nextPieceListener.nextPiece(currentPiece, followingPiece); - logger.info("Next piece is: {} and following piece is: {}", currentPiece, followingPiece); + logger.info("New current piece is: {} and following piece is: {}", currentPiece, followingPiece); return currentPiece; } @@ -185,7 +184,7 @@ public class Game { /** * Start new game loop */ - private void scheduleGameLoop() { + protected void scheduleGameLoop() { logger.info("Scheduling game loop"); if (gameTimer != null) gameTimer.cancel(); @@ -209,7 +208,6 @@ public class Game { public void endGame() { if (gameTimer != null) gameTimer.cancel(); Multimedia.playAudio("sounds/explode.wav"); - // TODO: do processing to end game - switch to ScoresScene } /** @@ -313,8 +311,8 @@ public class Game { * @param lines number of lines cleared * @param blocks number of blocks cleared */ - private void score(int lines, int blocks) { - logger.info("Updating score"); + protected void score(int lines, int blocks) { + logger.info("Updating score with lines cleared: {}, blocks cleared: {}", lines, blocks); var addScore = lines * blocks * 10 * multiplier.get(); score.set(score.get() + addScore); @@ -506,4 +504,17 @@ public class Game { showScoresListener = listener; } -} + /** + * Get leaderboard scores property - always null in this class + * @return null + */ + public SimpleListProperty>> leaderboardScoresProperty() { + return null; + } + + /** + * Set listener for game failure - but in this class does nothing + * @param listener listener to set + */ + public void setOnGameFail(GameFailureListener listener) {} +} \ No newline at end of file diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/game/Grid.java b/src/main/java/uk/mgrove/ac/soton/comp1206/game/Grid.java index 1cb91a4..5ddf407 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/game/Grid.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/game/Grid.java @@ -147,6 +147,9 @@ public class Grid { } } + /** + * Clear the grid + */ public void clearGrid() { for (var x = 0; x < getCols(); x++) { for (var y = 0; y < getRows(); y++) { diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/game/MultiplayerGame.java b/src/main/java/uk/mgrove/ac/soton/comp1206/game/MultiplayerGame.java new file mode 100644 index 0000000..0683c63 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/game/MultiplayerGame.java @@ -0,0 +1,231 @@ +package uk.mgrove.ac.soton.comp1206.game; + +import javafx.application.Platform; +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.scene.control.Alert; +import javafx.util.Pair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import uk.mgrove.ac.soton.comp1206.event.GameFailureListener; +import uk.mgrove.ac.soton.comp1206.network.Communicator; +import uk.mgrove.ac.soton.comp1206.util.Multimedia; + +import java.util.ArrayList; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.Scanner; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +public class MultiplayerGame extends Game { + + /** + * Logger + */ + private static final Logger logger = LogManager.getLogger(MultiplayerGame.class); + + /** + * Communicator to communicate with server + */ + private final Communicator communicator; + + /** + * Queue of pieces to be played in the game + */ + private BlockingQueue pieceQueue = new LinkedBlockingQueue<>(); + + /** + * Scores for the leaderboard + */ + private final SimpleListProperty>> leaderboardScores = new SimpleListProperty<>(FXCollections.observableArrayList(new ArrayList<>())); + + /** + * Listener for game failing + */ + private GameFailureListener gameFailureListener; + + /** + * Create a new game with the specified rows and columns. Creates a corresponding grid model. + * + * @param cols number of columns + * @param rows number of rows + */ + public MultiplayerGame(int cols, int rows, Communicator communicator) { + super(cols, rows); + + this.communicator = communicator; + communicator.addListener(this::handleCommunicatorMessage); + + for (var i=0; i<5; i++) communicator.send("PIECE"); + } + + /** + * Retrieve next game piece and request another from the server + * @return next piece + */ + @Override + public GamePiece spawnPiece() { + communicator.send("PIECE"); + + GamePiece nextPiece = null; + + try { + synchronized (pieceQueue) { + while (pieceQueue.size() == 0) { + wait(); + } + nextPiece = pieceQueue.poll(); + } + } catch (InterruptedException e) { + logger.error("Unable to retrieve piece from queue - waiting interrupted: {}", e); + Platform.runLater(() -> { + endGame(); + if (gameFailureListener != null) gameFailureListener.gameFail(); + var error = new Alert(Alert.AlertType.ERROR, "Unable to retrieve game data from server in time - perhaps your connection is slow?"); + error.showAndWait(); + }); + } + + logger.info("Picking next piece: {}", nextPiece.toString()); + return nextPiece; + } + + /** + * Handle incoming messages from the communicator + * @param message incoming message + */ + private void handleCommunicatorMessage(String message) { + if (message.startsWith("PIECE ")) { + try { + var newPiece = GamePiece.createPiece(Integer.parseInt(message.replaceFirst("PIECE ", ""))); + synchronized (pieceQueue) { + pieceQueue.add(newPiece); + } + logger.info("Generated piece from server: {}", newPiece.toString()); + } catch (NumberFormatException ex) { + logger.error("Unable to generate piece from server - piece value not a number: {}", ex); + } + } else if (message.startsWith("SCORES ")) { + logger.info("Setting scores from server"); + leaderboardScores.set(parseScores(message.replaceFirst("SCORES ", ""))); + } else if (message.startsWith("SCORE ")) { + var info = message.replaceFirst("SCORE ", "").split(":"); + + for (var i=0; i(info[0], new Pair<>(Integer.valueOf(info[1]), leaderboardScores.get(i).getValue().getValue()))); + break; + } + } + } else if (message.startsWith("DIE ")) { + var username = message.replaceFirst("DIE ", ""); + for (var i=0; i(username, new Pair<>(leaderboardScores.get(i).getValue().getKey(), -1))); + break; + } + } + } + } + + /** + * Parse high scores from a string to list property + * @param data string to parse from + * @return list property containing scores loaded + */ + private static SimpleListProperty>> parseScores(String data) { + logger.info("Parsing scores"); + + var scores = new SimpleListProperty>>(FXCollections.observableArrayList()); + var scanner = new Scanner(data); + + while (scanner.hasNextLine()) { + var line = scanner.nextLine(); + if (line.matches("^.+:[0-9]+:([0-9]+|DEAD)$")) { + var info = line.split(":"); + var lives = info[2].equals("DEAD") ? -1 : Integer.valueOf(info[2]); + scores.add(new Pair<>(info[0], new Pair<>(Integer.valueOf(info[1]), lives))); + } + } + scanner.close(); + + logger.info("Parsed scores: {}", scores); + return scores; + } + + /** + * Get the leaderboard scores property + * @return property for leaderboard scores + */ + public SimpleListProperty>> leaderboardScoresProperty() { + return leaderboardScores; + } + + /** + * End game + */ + @Override + public void endGame() { + super.endGame(); + logger.info("Sending die message to server"); + communicator.send("DIE"); + } + + /** + * Update the score, multiplier, and level depending on the number of lines and blocks that have been cleared + * @param lines number of lines cleared + * @param blocks number of blocks cleared + */ + @Override + protected void score(int lines, int blocks) { + var oldScore = score.get(); + super.score(lines, blocks); + if (oldScore != score.get()) { + logger.info("Updating server with new score: {}", score.get()); + communicator.send("SCORE " + score.get()); + } + } + + /** + * Set the player's remaining lives + */ + @Override + public void setLives(int lives) { + this.lives.set(lives); + communicator.send("LIVES " + lives); + } + + /** + * Play current piece at given coordinates + * @param x x-coordinate + * @param y y-coordinate + */ + @Override + public void dropPiece(int x, int y) { + var canPlayPiece = grid.canPlayPiece(currentPiece,x,y); + super.dropPiece(x,y); + if (canPlayPiece) { + var communicatorMessage = "BOARD"; + for (var i=0; i) (change, oldValue, newValue) -> { if (newValue.intValue() > highScore.get()) highScore.set(newValue.intValue()); }); var rightMenu = new VBox(); - rightMenu.getChildren().addAll(statsMenu,currentPieceBoard,followingPieceBoard,topHighScoreText); + rightMenu.setSpacing(16); + rightMenu.getChildren().addAll(statsMenu,currentPieceBoard,followingPieceBoard,topHighScoreContainer); mainPane.setRight(rightMenu); timerCountdownBar = new Rectangle(gameWindow.getWidth()/2f,48); BorderPane.setAlignment(timerCountdownBar, Pos.BOTTOM_CENTER); - // TODO: alignment of timer bar mainPane.setBottom(timerCountdownBar); @@ -142,7 +151,7 @@ public class ChallengeScene extends BaseScene { * Handle when a block is clicked * @param gameBlock the Game Block that was clocked */ - private void blockClicked(GameBlock gameBlock) { + protected void blockClicked(GameBlock gameBlock) { game.blockClicked(gameBlock); } @@ -150,7 +159,8 @@ public class ChallengeScene extends BaseScene { * Handle keypress for game keyboard controls * @param event the keypress */ - private void handleKeyboardControls(KeyEvent event) { + protected void handleKeyboardControls(KeyEvent event) { + logger.info("Handling keyboard input: {}", event.getCode()); switch (event.getCode()) { case ESCAPE -> returnToMenu(); case ENTER, X -> game.dropPiece(board.getXFocus(),board.getYFocus()); @@ -200,13 +210,13 @@ public class ChallengeScene extends BaseScene { gameWindow.startMenu(); } - private void showScores(Game game) { + protected void showScores(Game game) { if (timerCountdownBarFillTransition != null) timerCountdownBarFillTransition.stop(); if (timerCountdownBarScaleTransition != null) timerCountdownBarScaleTransition.stop(); gameWindow.startScores(game); } - private void setCurrentPiece(GamePiece currentPiece, GamePiece followingPiece) { + protected void setCurrentPiece(GamePiece currentPiece, GamePiece followingPiece) { currentPieceBoard.displayPiece(currentPiece); followingPieceBoard.displayPiece(followingPiece); } @@ -215,7 +225,7 @@ public class ChallengeScene extends BaseScene { * Reset the countdown progress bar with new game timer delay when the game loop is scheduled * @param timerDelay delay until game loop is executed */ - private void scheduleCountdown(int timerDelay) { + protected void scheduleCountdown(int timerDelay) { logger.info("Scheduling UI countdown timer"); timerCountdownBar.setArcHeight(32); @@ -233,7 +243,7 @@ public class ChallengeScene extends BaseScene { * Get top high score * @return top high score */ - private int getHighScore() { + protected int getHighScore() { return ScoresScene.getScores("scores.txt").get(0).getValue(); } diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/InstructionsScene.java b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/InstructionsScene.java index 339df12..f5302e9 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/InstructionsScene.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/InstructionsScene.java @@ -1,5 +1,6 @@ package uk.mgrove.ac.soton.comp1206.scene; +import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; @@ -8,6 +9,7 @@ import javafx.scene.layout.GridPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import uk.mgrove.ac.soton.comp1206.component.PieceBoard; @@ -54,20 +56,18 @@ public class InstructionsScene extends BaseScene { */ @Override public void build() { - // TODO: styling and insets - logger.info("Building " + this.getClass().getName()); root = new GamePane(gameWindow.getWidth(),gameWindow.getHeight()); - var menuPane = new StackPane(); - menuPane.setMaxWidth(gameWindow.getWidth()); - menuPane.setMaxHeight(gameWindow.getHeight()); - menuPane.getStyleClass().add("menu-background"); - root.getChildren().add(menuPane); + var instructionsPane = new StackPane(); + instructionsPane.setMaxWidth(gameWindow.getWidth()); + instructionsPane.setMaxHeight(gameWindow.getHeight()); + instructionsPane.getStyleClass().add("menu-background"); + root.getChildren().add(instructionsPane); var mainPane = new BorderPane(); - menuPane.getChildren().add(mainPane); + instructionsPane.getChildren().add(mainPane); //Awful title var title = new Text("How to Play"); @@ -75,10 +75,18 @@ public class InstructionsScene extends BaseScene { mainPane.setTop(title); BorderPane.setAlignment(title, Pos.TOP_CENTER); + var instructionsText = new Text("TetrECS is a fast-paced gravity-free block placement game, where you must survive by clearing rows through careful placement of the upcoming blocks before the time runs out. Lose all 3 lives and you're destroyed!"); + instructionsText.getStyleClass().add("instructions"); + instructionsText.setWrappingWidth(700); + instructionsText.setTextAlignment(TextAlignment.CENTER); + var instructions = new ImageView(getClass().getResource("/images/Instructions.png").toExternalForm()); instructions.setPreserveRatio(true); - instructions.setFitHeight(gameWindow.getHeight()*0.55); - mainPane.setCenter(instructions); + instructions.setFitHeight(gameWindow.getHeight()*0.5); + + var instructionsContainer = new VBox(instructionsText, instructions); + instructionsContainer.setAlignment(Pos.TOP_CENTER); + mainPane.setCenter(instructionsContainer); var pieceGrid = new GridPane(); pieceGrid.setVgap(8); diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/LoadingScene.java b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/LoadingScene.java new file mode 100644 index 0000000..e408046 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/LoadingScene.java @@ -0,0 +1,81 @@ +package uk.mgrove.ac.soton.comp1206.scene; + +import javafx.animation.FadeTransition; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import javafx.util.Duration; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import uk.mgrove.ac.soton.comp1206.ui.GamePane; +import uk.mgrove.ac.soton.comp1206.ui.GameWindow; +import uk.mgrove.ac.soton.comp1206.util.Multimedia; + +import java.util.Timer; +import java.util.TimerTask; + +public class LoadingScene extends BaseScene { + + /** + * Logger + */ + private static final Logger logger = LogManager.getLogger(LoadingScene.class); + + /** + * Fade transition for ECS Games logo + */ + private FadeTransition logoFade; + + /** + * Create a new scene, passing in the GameWindow the scene will be displayed in + * + * @param gameWindow the game window + */ + public LoadingScene(GameWindow gameWindow) { + super(gameWindow); + } + + /** + * Build the layout of the scene + */ + @Override + public void build() { + logger.info("Building " + this.getClass().getName()); + + root = new GamePane(gameWindow.getWidth(),gameWindow.getHeight()); + + var logo = new ImageView(getClass().getResource("/images/ECSGames.png").toExternalForm()); + logo.setPreserveRatio(true); + logo.setFitWidth(380); + root.getChildren().add(logo); + StackPane.setAlignment(logo, Pos.CENTER); + + logoFade = new FadeTransition(Duration.millis(2000), logo); + logoFade.setFromValue(0); + logoFade.setToValue(1); + + logo.setOpacity(0); + } + + /** + * Initialise this scene. Called after creation + */ + @Override + public void initialise() { + logger.info("Initialising " + this.getClass().getName()); + + logoFade.play(); + Multimedia.playAudio("sounds/intro.mp3"); + + TimerTask loadMenuTimerTask = new TimerTask() { + @Override + public void run() { + Platform.runLater(() -> gameWindow.startMenu()); + } + }; + var loadMenuTimer = new Timer("Timer"); + loadMenuTimer.schedule(loadMenuTimerTask, 4000); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/LobbyScene.java b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/LobbyScene.java new file mode 100644 index 0000000..2cda467 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/LobbyScene.java @@ -0,0 +1,304 @@ +package uk.mgrove.ac.soton.comp1206.scene; + +import javafx.application.Platform; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import uk.mgrove.ac.soton.comp1206.component.Chat; +import uk.mgrove.ac.soton.comp1206.ui.GamePane; +import uk.mgrove.ac.soton.comp1206.ui.GameWindow; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.function.Predicate; + +public class LobbyScene extends BaseScene { + + /** + * Logger + */ + private static final Logger logger = LogManager.getLogger(LobbyScene.class); + + /** + * Container for all channels + */ + private final VBox allChannels = new VBox(); + + /** + * Container for controls to create new channel + */ + private final VBox addChannelContainer = new VBox(); + + /** + * Button to open prompt to create new channel + */ + private final Text promptToCreateChannel = new Text("Create channel"); + + private final TextField newChannelName = new TextField(); + + /** + * Button to create new channel + */ + private final Text createChannel = new Text("Create"); + + /** + * Container to list all users in channel + */ + private final HBox channelUsers = new HBox(); + + /** + * Main content of scene (i.e. current channel actions) + */ + private final VBox mainContent = new VBox(); + + /** + * Current channel + */ + private String currentChannel; + + /** + * Whether use is host of current channel + */ + private boolean isChannelHost = false; + + /** + * Chat container + */ + private Chat chat; + + /** + * Container for control buttons when in a channel + */ + private final HBox channelFunctionButtons = new HBox(); + + /** + * Create a new scene, passing in the GameWindow the scene will be displayed in + * + * @param gameWindow the game window + */ + public LobbyScene(GameWindow gameWindow) { + super(gameWindow); + } + + /** + * Build the layout of the scene + */ + @Override + public void build() { + logger.info("Building " + this.getClass().getName()); + + root = new GamePane(gameWindow.getWidth(), gameWindow.getHeight()); + + var menuPane = new StackPane(); + menuPane.setMaxWidth(gameWindow.getWidth()); + menuPane.setMaxHeight(gameWindow.getHeight()); + menuPane.getStyleClass().add("menu-background"); + root.getChildren().add(menuPane); + + var mainPane = new BorderPane(); + menuPane.getChildren().add(mainPane); + + //Awful title + var title = new Text("TetrECS Lobby"); + BorderPane.setAlignment(title, Pos.TOP_CENTER); + title.getStyleClass().add("title"); + mainPane.setTop(title); + + var leftMenu = new VBox(); + leftMenu.setSpacing(8); + BorderPane.setMargin(leftMenu, new Insets(0,30,0,0)); + + createChannel.setOnMouseClicked(this::createChannel); + createChannel.getStyleClass().add("channelItem"); + newChannelName.setOnAction(this::createChannel); + newChannelName.setPromptText("Channel name"); + promptToCreateChannel.setOnMouseClicked((event) -> { + addChannelContainer.getChildren().clear(); + addChannelContainer.getChildren().addAll(newChannelName, createChannel); + newChannelName.requestFocus(); + }); + promptToCreateChannel.getStyleClass().add("channelItem"); + addChannelContainer.getChildren().add(promptToCreateChannel); + addChannelContainer.setSpacing(4); + + allChannels.setSpacing(4); + var channelsTitle = new Text("Channels"); + channelsTitle.getStyleClass().add("heading"); + leftMenu.getChildren().addAll(channelsTitle, allChannels, addChannelContainer); + + chat = new Chat(gameWindow.getCommunicator(), false); + + mainContent.setSpacing(12); + + mainPane.setLeft(leftMenu); + mainPane.setCenter(mainContent); + + gameWindow.getCommunicator().addListener(this::handleCommunicatorMessage); + + var listChannelsRequest = new Timer("Request list of channels from communicator"); + listChannelsRequest.schedule(new TimerTask() { + @Override + public void run() { + gameWindow.getCommunicator().send("LIST"); + } + }, 0, 10000); + + channelUsers.setSpacing(8); + channelFunctionButtons.setSpacing(8); + } + + /** + * Initialise this scene. Called after creation + */ + @Override + public void initialise() { + logger.info("Initialising Lobby"); + + // exit lobby when escape key pressed + scene.setOnKeyPressed((event) -> { + if (event.getCode() == KeyCode.ESCAPE) { + gameWindow.startMenu(); + } + }); + + getScene().addPostLayoutPulseListener(this::scrollChatToBottom); + } + + /** + * Scroll to bottom of messages list. + */ + private void scrollChatToBottom() { + chat.scrollToBottom(); + } + + /** + * Handle incoming message from communicator + * + * @param message incoming message + */ + private void handleCommunicatorMessage(String message) { + if (message.startsWith("CHANNELS ")) { + logger.info("List of channels received from communicator"); + var data = message.replaceFirst("CHANNELS ", "").split("\n"); + Platform.runLater(() -> { + allChannels.getChildren().clear(); + for (var channel : data) { + if (!channel.isBlank()) { + var channelButton = new Text(channel); + channelButton.getStyleClass().add("channelItem"); + channelButton.setOnMouseClicked((event) -> { + if (currentChannel != null) gameWindow.getCommunicator().send("PART"); + gameWindow.getCommunicator().send("JOIN " + channel); + }); + allChannels.getChildren().add(channelButton); + } + } + }); + } else if (message.startsWith("USERS ")) { + logger.info("List of users in channel received from communicator"); + var data = message.replaceFirst("USERS ", "").split("\n"); + Platform.runLater(() -> { + channelUsers.getChildren().clear(); + var usersTitle = new Text("Users:"); + channelUsers.getChildren().add(usersTitle); + for (var user : data) { + var newUser = new Text(user); + newUser.setId(user); + channelUsers.getChildren().add(newUser); + } + }); + } else if (message.equals("PARTED")) { + logger.info("Left channel: {}", currentChannel); + Platform.runLater(() -> { + gameWindow.getCommunicator().send("LIST"); + mainContent.getChildren().clear(); + chat.clearMessages(); + channelFunctionButtons.getChildren().clear(); + isChannelHost = false; + currentChannel = null; + }); + } else if (message.equals("HOST")) { + logger.info("User is host of current channel"); + isChannelHost = true; + Platform.runLater(() -> { + logger.info("Current channel host status: {}", isChannelHost); + var startGame = new Text("Start game"); + startGame.getStyleClass().add("channelItem"); + startGame.setOnMouseClicked((event) -> { + gameWindow.getCommunicator().send("START"); + }); + channelFunctionButtons.getChildren().add(0, startGame); + }); + } else if (message.matches("^NICK .+:.+$")) { + Platform.runLater(() -> { + var usernames = message.replaceFirst("NICK ", "").split(":"); + if (channelUsers.getChildren().removeIf(user -> user.getId().equals(usernames[0]))) { + var newUser = new Text(usernames[1]); + newUser.setId(usernames[1]); + channelUsers.getChildren().add(newUser); + } + }); + } else if (message.startsWith("JOIN ")) { + Platform.runLater(() -> joinChannel(message.replaceFirst("JOIN ", ""))); + } else if (message.startsWith("ERROR ")) { + var errorText = message.replaceFirst("ERROR ", ""); + logger.info("Received an error: {}", errorText); + Platform.runLater(() -> { + var error = new Alert(Alert.AlertType.ERROR, "Can't perform that action:\n\n" + errorText); + error.showAndWait(); + }); + } else if (message.equals("START")) { + logger.info("Starting game"); + Platform.runLater(gameWindow::startMultiplayerGame); + } + } + + /** + * Create new channel on action event + * + * @param event triggering event (button click or text field submit) + */ + private void createChannel(Event event) { + if (currentChannel != null) gameWindow.getCommunicator().send("PART"); + gameWindow.getCommunicator().send("CREATE " + newChannelName.getText()); + gameWindow.getCommunicator().send("LIST"); + newChannelName.clear(); + addChannelContainer.getChildren().clear(); + addChannelContainer.getChildren().add(promptToCreateChannel); + } + + /** + * Join channel by name + * + * @param channelName name of channel + */ + private void joinChannel(String channelName) { + logger.info("Joined channel: {}", channelName); + var leaveChannel = new Text("Leave channel"); + leaveChannel.getStyleClass().add("channelItem"); + leaveChannel.setOnMouseClicked((event) -> { + gameWindow.getCommunicator().send("PART"); + }); + channelFunctionButtons.getChildren().add(leaveChannel); + + var channelTitle = new Text(channelName); + channelTitle.getStyleClass().add("heading"); + + mainContent.getChildren().addAll(channelTitle, channelFunctionButtons, channelUsers, new Separator(), chat); + chat.focusInputField(); + + currentChannel = channelName; + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MenuScene.java b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MenuScene.java index e79aabd..78e67bf 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MenuScene.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MenuScene.java @@ -1,11 +1,20 @@ package uk.mgrove.ac.soton.comp1206.scene; +import javafx.animation.Animation; +import javafx.animation.RotateTransition; import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.*; import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import javafx.util.Duration; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import uk.mgrove.ac.soton.comp1206.App; import uk.mgrove.ac.soton.comp1206.ui.GamePane; import uk.mgrove.ac.soton.comp1206.ui.GameWindow; import uk.mgrove.ac.soton.comp1206.util.Multimedia; @@ -20,6 +29,11 @@ public class MenuScene extends BaseScene { */ private static final Logger logger = LogManager.getLogger(MenuScene.class); + /** + * Rotate transition for TetrECS logo + */ + private RotateTransition titleAnimation; + /** * Create a new menu scene * @param gameWindow the Game Window this will be displayed in @@ -47,27 +61,39 @@ public class MenuScene extends BaseScene { var mainPane = new BorderPane(); menuPane.getChildren().add(mainPane); - //Awful title - var title = new Text("TetrECS"); - title.getStyleClass().add("title"); - mainPane.setTop(title); + var title = new ImageView(getClass().getResource("/images/TetrECS.png").toExternalForm()); + title.setPreserveRatio(true); + title.setFitWidth(400); + titleAnimation = new RotateTransition(Duration.millis(2000), title); + titleAnimation.setFromAngle(7); + titleAnimation.setToAngle(-7); + titleAnimation.setAutoReverse(true); + titleAnimation.setCycleCount(Animation.INDEFINITE); + + var separator = new VBox(); + separator.setPrefHeight(70); var menuItems = new VBox(); - var singlePlayerButton = new Button("Single Player"); - var multiPlayerButton = new Button("Multi Player"); - var howToPlayButton = new Button("How to Play"); - var exitButton = new Button("Exit"); - menuItems.getChildren().addAll(singlePlayerButton,multiPlayerButton,howToPlayButton,exitButton); - mainPane.setCenter(menuItems); + menuItems.setAlignment(Pos.CENTER); + var singlePlayerButton = new Text("Single Player"); + var multiPlayerButton = new Text("Multiplayer"); + var howToPlayButton = new Text("How to Play"); + var exitButton = new Text("Exit"); - // TOOD: window animations + menuItems.getChildren().addAll(title,separator,singlePlayerButton,multiPlayerButton,howToPlayButton,exitButton); + mainPane.setCenter(menuItems); Multimedia.playMusic("music/menu.mp3"); //Bind the button action to the startGame method in the menu - singlePlayerButton.setOnAction(this::startSinglePlayer); - multiPlayerButton.setOnAction(this::startMultiPlayer); - howToPlayButton.setOnAction(this::showInstructions); + singlePlayerButton.setOnMouseClicked(this::startSinglePlayer); + singlePlayerButton.getStyleClass().add("menuItem"); + multiPlayerButton.setOnMouseClicked(this::startMultiplayer); + multiPlayerButton.getStyleClass().add("menuItem"); + howToPlayButton.setOnMouseClicked(this::showInstructions); + howToPlayButton.getStyleClass().add("menuItem"); + exitButton.setOnMouseClicked(this::shutdown); + exitButton.getStyleClass().add("menuItem"); } /** @@ -75,14 +101,17 @@ public class MenuScene extends BaseScene { */ @Override public void initialise() { + logger.info("Initialising " + this.getClass().getName()); + titleAnimation.play(); } /** * Handle when the Start Single Player Game button is pressed * @param event event */ - private void startSinglePlayer(ActionEvent event) { + private void startSinglePlayer(MouseEvent event) { + titleAnimation.stop(); gameWindow.startChallenge(); } @@ -90,7 +119,8 @@ public class MenuScene extends BaseScene { * Handle when the Start Multiplayer Game button is pressed * @param event event */ - private void startMultiPlayer(ActionEvent event) { + private void startMultiplayer(MouseEvent event) { + titleAnimation.stop(); gameWindow.startMultiplayer(); } @@ -98,8 +128,17 @@ public class MenuScene extends BaseScene { * Handle when the How to Play button is pressed * @param event event */ - private void showInstructions(ActionEvent event) { + private void showInstructions(MouseEvent event) { + titleAnimation.stop(); gameWindow.startInstructions(); } + /** + * Handle when the exit button is pressed + * @param event event + */ + private void shutdown(MouseEvent event) { + App.getInstance().shutdown(); + } + } diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MultiplayerScene.java b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MultiplayerScene.java new file mode 100644 index 0000000..9ca9f04 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/MultiplayerScene.java @@ -0,0 +1,85 @@ +package uk.mgrove.ac.soton.comp1206.scene; + +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Pos; +import javafx.scene.control.Separator; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Text; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import uk.mgrove.ac.soton.comp1206.component.*; +import uk.mgrove.ac.soton.comp1206.game.Game; +import uk.mgrove.ac.soton.comp1206.game.MultiplayerGame; +import uk.mgrove.ac.soton.comp1206.ui.GamePane; +import uk.mgrove.ac.soton.comp1206.ui.GameWindow; +import uk.mgrove.ac.soton.comp1206.util.Multimedia; + +/** + * Class for multiplayer game scene + */ +public class MultiplayerScene extends ChallengeScene { + + /** + * Logger + */ + private static final Logger logger = LogManager.getLogger(MultiplayerScene.class); + + /** + * Chat container + */ + private Chat chat; + + /** + * Live leaderboard + */ + private Leaderboard leaderboard; + + /** + * Create a new multiplayer challenge scene + * + * @param gameWindow the Game Window + */ + public MultiplayerScene(GameWindow gameWindow) { + super(gameWindow); + } + + /** + * Set up the game object and model + */ + @Override + public void setupGame() { + logger.info("Starting a new challenge"); + + //Start new game + game = new MultiplayerGame(5, 5, gameWindow.getCommunicator()); + game.setOnGameFail(() -> Platform.runLater(() -> gameWindow.startMenu())); + + gameWindow.getCommunicator().send("SCORES"); + } + + /** + * Build the Multiplayer Challenge window + */ + @Override + public void build() { + super.build(); + var leftMenu = new VBox(); + leftMenu.setSpacing(8); + leftMenu.setMaxWidth(200); + leftMenu.setPrefWidth(200); + chat = new Chat(gameWindow.getCommunicator(), true); + chat.setChatFocusTraversable(false); + leaderboard = new Leaderboard("Leaderboard"); + var chatTitle = new Text("Chat"); + chatTitle.getStyleClass().add("heading"); + leftMenu.getChildren().addAll(leaderboard, new Separator(), chatTitle, chat); + mainPane.setLeft(leftMenu); + leaderboard.bindLeaderboardScores(game.leaderboardScoresProperty()); + mainPane.requestFocus(); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/ScoresScene.java b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/ScoresScene.java index 42d47f0..b1b28e1 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/scene/ScoresScene.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/scene/ScoresScene.java @@ -1,19 +1,29 @@ package uk.mgrove.ac.soton.comp1206.scene; +import javafx.animation.FadeTransition; +import javafx.application.Platform; import javafx.beans.property.SimpleListProperty; import javafx.collections.FXCollections; -import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import javafx.util.Duration; import javafx.util.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import uk.mgrove.ac.soton.comp1206.component.ScoresList; import uk.mgrove.ac.soton.comp1206.game.Game; +import uk.mgrove.ac.soton.comp1206.game.MultiplayerGame; import uk.mgrove.ac.soton.comp1206.ui.GamePane; import uk.mgrove.ac.soton.comp1206.ui.GameWindow; @@ -23,6 +33,7 @@ import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; import java.util.Scanner; +import java.util.concurrent.atomic.AtomicBoolean; /** * Scene to show scores and record high scores @@ -42,7 +53,12 @@ public class ScoresScene extends BaseScene { /** * List of scores stored locally */ - private SimpleListProperty> localScores; + private final SimpleListProperty> localScores = new SimpleListProperty<>(FXCollections.observableArrayList(new ArrayList<>())); + + /** + * List of online scores + */ + private final SimpleListProperty> remoteScores = new SimpleListProperty<>(FXCollections.observableArrayList(new ArrayList<>())); /** * Root pane in scene inside the GamePane @@ -54,10 +70,20 @@ public class ScoresScene extends BaseScene { */ private ScoresList scoresUiList; + /** + * Node to display online high scores + */ + private ScoresList remoteScoresUiList; + /** * Container for prompting for username when high score achieved */ - private VBox userNamePrompt; + private VBox highScorePromptContainer; + + /** + * Main container for pane + */ + private VBox mainContainer; /** * Create a new scores scene @@ -88,15 +114,37 @@ public class ScoresScene extends BaseScene { var mainPane = new BorderPane(); scoresPane.getChildren().add(mainPane); - ArrayList> scoresList = new ArrayList<>(); - ObservableList> observableScoresList = FXCollections.observableArrayList(scoresList); - localScores = new SimpleListProperty<>(observableScoresList); - loadScores("scores.txt"); - if (game.getScore() > localScores.get(localScores.getSize() - 1).getValue()) showHighScorePrompt(game.getScore()); + String localScoresTitle; + if (game.getClass().equals(MultiplayerGame.class)) { + logger.info("Loading multiplayer scores from game"); + var multiplayerScores = FXCollections.observableArrayList(new ArrayList>()); + for (var score : game.leaderboardScoresProperty().get()) { + multiplayerScores.add(new Pair<>(score.getKey(),score.getValue().getKey())); + } + localScores.set(multiplayerScores); + localScoresTitle = "Game Scores"; + } else { + loadScores("scores.txt"); + localScoresTitle = "Local Scores"; + } + loadOnlineScores(); - scoresUiList = new ScoresList(); + scoresUiList = new ScoresList(localScoresTitle); scoresUiList.bindScores(localScores); - mainPane.setCenter(scoresUiList); + scoresUiList.getStyleClass().add("scorelist"); + + remoteScoresUiList = new ScoresList("Online Scores"); + remoteScoresUiList.bindScores(remoteScores); + remoteScoresUiList.getStyleClass().add("scorelist"); + + var scoresContainer = new HBox(); + scoresContainer.setSpacing(40); + scoresContainer.getChildren().addAll(scoresUiList, remoteScoresUiList); + scoresContainer.setAlignment(Pos.CENTER); + mainContainer = new VBox(scoresContainer); + mainContainer.setSpacing(60); + mainContainer.setAlignment(Pos.CENTER); + mainPane.setCenter(mainContainer); } /** @@ -104,6 +152,26 @@ public class ScoresScene extends BaseScene { */ @Override public void initialise() { + logger.info("Initialising Scores"); + + // exit instructions when escape key pressed + scene.setOnKeyPressed((event) -> { + if (event.getCode() == KeyCode.ESCAPE) { + gameWindow.startMenu(); + } + }); + } + + /** + * Return to lobby on mouse click + * @param event mouse click event + */ + private void returnToLobby(MouseEvent event) { + if (game.getClass().equals(MultiplayerGame.class)) { + gameWindow.startMultiplayer(); + } else { + gameWindow.startMenu(); + } } /** @@ -112,7 +180,34 @@ public class ScoresScene extends BaseScene { */ public void loadScores(String filePath) { logger.info("Loading scores from file: {}", filePath); - localScores = getScores(filePath); + localScores.set(getScores(filePath)); + } + + /** + * Load high scores from server and reveal both local and remote scores + */ + public void loadOnlineScores() { + logger.info("Loading online scores"); + AtomicBoolean scoresLoaded = new AtomicBoolean(false); + gameWindow.getCommunicator().addListener((message) -> { + if (!scoresLoaded.get() && message.startsWith("HISCORES ")) { + scoresLoaded.set(true); + var strippedMessage = message.replaceFirst("HISCORES ", ""); + remoteScores.set(parseScores(new Scanner(strippedMessage))); + var topLocalScore = localScores.getSize() > 0 ? localScores.get(localScores.getSize() - 1).getValue() : 0; + var topRemoteScore = remoteScores.size() > 0 ? remoteScores.get(remoteScores.getSize() - 1).getValue() : 0; + Platform.runLater(() -> { + if (game.getScore() > topLocalScore || game.getScore() > topRemoteScore) + showHighScorePrompt(game.getScore()); + else { + scoresUiList.reveal(); + remoteScoresUiList.reveal(); + showPlayAgainButton(); + } + }); + } + }); + gameWindow.getCommunicator().send("HISCORES"); } /** @@ -123,22 +218,15 @@ public class ScoresScene extends BaseScene { public static SimpleListProperty> getScores(String filePath) { logger.info("Retrieving scores from file: {}", filePath); - var scores = new SimpleListProperty>(FXCollections.observableArrayList()); + SimpleListProperty> scores; try { File scoresFile = new File(filePath); Scanner scanner = new Scanner(scoresFile); - while (scanner.hasNextLine()) { - var line = scanner.nextLine(); - if (line.matches("^.+:[0-9]+$")) { - int splitIndex = line.lastIndexOf(":"); - String[] info = {line.substring(0,splitIndex), line.substring(splitIndex+1)}; - scores.add(new Pair<>(info[0], Integer.valueOf(info[1]))); - } - } - scanner.close(); + scores = parseScores(scanner); } catch (FileNotFoundException e1) { logger.error("Unable to load scores file, writing default scores instead: {}", filePath); + scores = new SimpleListProperty<>(FXCollections.observableArrayList()); scores.add(new Pair<>("Sam",3000)); scores.add(new Pair<>("Jane",2000)); scores.add(new Pair<>("Pete",1000)); @@ -148,6 +236,28 @@ public class ScoresScene extends BaseScene { return scores; } + /** + * Parse high scores from a scanner to list property + * @param scanner scanner to parse from + * @return list property containing scores loaded + */ + private static SimpleListProperty> parseScores(Scanner scanner) { + var scores = new SimpleListProperty>(FXCollections.observableArrayList()); + + while (scanner.hasNextLine()) { + var line = scanner.nextLine(); + if (line.matches("^.+:[0-9]+$")) { + int splitIndex = line.lastIndexOf(":"); + String[] info = {line.substring(0,splitIndex), line.substring(splitIndex+1)}; + scores.add(new Pair<>(info[0], Integer.valueOf(info[1]))); + } + } + scanner.close(); + + logger.info("Parsed scores: {}", scores); + return scores; + } + /** * Write list of scores to file * @param filePath file to write to @@ -175,14 +285,54 @@ public class ScoresScene extends BaseScene { private void showHighScorePrompt(int score) { logger.info("Showing high score username input prompt"); - userNamePrompt = new VBox(); + var usernameTitle = new Text("Enter username"); + usernameTitle.getStyleClass().add("title"); + usernameTitle.setTextAlignment(TextAlignment.CENTER); + var userNamePrompt = new HBox(); + userNamePrompt.setAlignment(Pos.CENTER); + userNamePrompt.setSpacing(4); var userNameInput = new TextField(); userNameInput.setPromptText("Enter username..."); - userNameInput.setOnAction((event) -> saveHighScore(userNameInput.getText(), score)); - var userNameSubmit = new Button("Submit"); - userNameSubmit.setOnAction((event) -> saveHighScore(userNameInput.getText(), score)); - userNamePrompt.getChildren().addAll(new Text("Username:"), userNameInput, userNameSubmit); - scoresPane.getChildren().add(userNamePrompt); + var userNameSubmit = new Text("Submit"); + userNameSubmit.getStyleClass().add("channelItem"); + + userNameInput.setOnAction((event) -> submitHighScore(score, userNameInput.getText())); + userNameSubmit.setOnMouseClicked((event) -> submitHighScore(score, userNameInput.getText())); + + userNamePrompt.getChildren().addAll(userNameInput, userNameSubmit); + highScorePromptContainer = new VBox(usernameTitle, userNamePrompt); + highScorePromptContainer.setSpacing(20); + scoresPane.getChildren().addAll(highScorePromptContainer); + StackPane.setAlignment(highScorePromptContainer, Pos.CENTER); + } + + /** + * Submit a high score + * @param score score to submit + * @param username username of player + */ + private void submitHighScore(int score, String username) { + if (!username.isBlank()) { + saveHighScore(username, score); + writeOnlineScore(username, score); + scoresPane.getChildren().remove(highScorePromptContainer); + scoresUiList.reveal(); + remoteScoresUiList.reveal(); + showPlayAgainButton(); + } + } + + private void showPlayAgainButton() { + var playAgainButton = new Text("Continue"); + playAgainButton.getStyleClass().add("menuItem"); + playAgainButton.setOnMouseClicked(this::returnToLobby); + playAgainButton.setOpacity(0); + + var ft = new FadeTransition(Duration.millis(1000), playAgainButton); + ft.setToValue(1); + + mainContainer.getChildren().add(playAgainButton); + ft.play(); } /** @@ -193,8 +343,8 @@ public class ScoresScene extends BaseScene { private void saveHighScore(String username, int score) { logger.info("Saving high score: {} for user: {}", score, username); - for (var i = 0; i < localScores.getSize(); i++) { - if (localScores.get(i).getValue() < score) { + for (var i = 0; i <= localScores.getSize(); i++) { + if (i == localScores.getSize() || localScores.get(i).getValue() < score) { localScores.add(i, new Pair<>(username, score)); break; } @@ -202,8 +352,19 @@ public class ScoresScene extends BaseScene { // if score isn't higher than last high score then this shouldn't have been triggered // so while it won't get stored, this isn't an issue writeScores("scores.txt", localScores); - scoresPane.getChildren().remove(userNamePrompt); - scoresUiList.reveal(); + } + + private void writeOnlineScore(String username, int score) { + logger.info("Saving online high score: {} for user: {}", score, username); + + gameWindow.getCommunicator().send("HISCORE " + username + ":" + (score)); + + for (var i = 0; i <= remoteScores.getSize(); i++) { + if (i == remoteScores.getSize() || remoteScores.get(i).getValue() < score) { + remoteScores.add(i, new Pair<>(username, score)); + break; + } + } } /** diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/GameWindow.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/GameWindow.java index a5d03a9..bec98ab 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/GameWindow.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/GameWindow.java @@ -1,11 +1,13 @@ package uk.mgrove.ac.soton.comp1206.ui; +import javafx.animation.FadeTransition; import javafx.application.Platform; import javafx.scene.Scene; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.stage.Stage; +import javafx.util.Duration; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import uk.mgrove.ac.soton.comp1206.App; @@ -61,8 +63,7 @@ public class GameWindow { //Setup communicator communicator = new Communicator("ws://ofb-labs.soton.ac.uk:9700"); - //Go to menu - startMenu(); + startLoadingScreen(); } /** @@ -77,6 +78,13 @@ public class GameWindow { Font.loadFont(getClass().getResourceAsStream("/style/Orbitron-ExtraBold.ttf"),32); } + /** + * Display the loading screen + */ + public void startLoadingScreen() { + loadScene(new LoadingScene(this)); + } + /** * Display the main menu */ @@ -92,10 +100,17 @@ public class GameWindow { } /** - * Display the multiplayer challenge + * Display the multiplayer lobby */ public void startMultiplayer() { - // TODO: load multiplayer scene + loadScene(new LobbyScene(this)); + } + + /** + * Display the multiplayer challenge + */ + public void startMultiplayerGame() { + loadScene(new MultiplayerScene(this)); } /** @@ -129,11 +144,36 @@ public class GameWindow { //Create the new scene and set it up newScene.build(); currentScene = newScene; - scene = newScene.setScene(); - stage.setScene(scene); - //Initialise the scene when ready - Platform.runLater(() -> currentScene.initialise()); + if (scene != null) { + scene = newScene.setScene(); + + var fadeOut = new FadeTransition(Duration.millis(200), stage.getScene().getRoot()); + fadeOut.setFromValue(1); + fadeOut.setToValue(0); + + var fadeIn = new FadeTransition(Duration.millis(200), scene.getRoot()); + fadeIn.setFromValue(0); + fadeIn.setToValue(1); + + fadeOut.setOnFinished((event) -> { + stage.setScene(scene); + + fadeIn.play(); + + //Initialise the scene when ready + Platform.runLater(() -> currentScene.initialise()); + }); + + fadeOut.play(); + } else { + scene = newScene.setScene(); + + stage.setScene(scene); + + //Initialise the scene when ready + Platform.runLater(() -> currentScene.initialise()); + } } /** diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/util/Multimedia.java b/src/main/java/uk/mgrove/ac/soton/comp1206/util/Multimedia.java index d18fdb5..f1ffee4 100644 --- a/src/main/java/uk/mgrove/ac/soton/comp1206/util/Multimedia.java +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/util/Multimedia.java @@ -34,7 +34,7 @@ public class Multimedia { if (audioPlayer != null) audioPlayer.stop(); var media = new Media(Multimedia.class.getResource("/" + filePath).toExternalForm()); audioPlayer = new MediaPlayer(media); - audioPlayer.play(); +// audioPlayer.play(); } /** @@ -47,9 +47,8 @@ public class Multimedia { if (musicPlayer != null) musicPlayer.stop(); var media = new Media(Multimedia.class.getResource("/" + filePath).toExternalForm()); musicPlayer = new MediaPlayer(media); - musicPlayer.setAutoPlay(true); +// musicPlayer.setAutoPlay(true); musicPlayer.setCycleCount(MediaPlayer.INDEFINITE); - musicPlayer.play(); } } diff --git a/src/main/resources/style/game.css b/src/main/resources/style/game.css index 2b77c42..7a2bbfb 100644 --- a/src/main/resources/style/game.css +++ b/src/main/resources/style/game.css @@ -6,9 +6,18 @@ -fx-background-color: black; } +Text { + -fx-fill: white; +} + +Label { + -fx-text-fill: white; +} + .menu-background { -fx-background-image: url("../images/1.jpg"); -fx-background-size: cover; + -fx-padding: 20; } .challenge-background { @@ -148,6 +157,9 @@ -fx-border-color: black; -fx-stroke: black; } +.channelItem:hover { + -fx-fill: yellow; +} .channelItem.selected { -fx-fill: yellow; } @@ -181,7 +193,7 @@ -fx-fill: white; } -TextField { +TextField, .text-field { -fx-border-color: white; -fx-border-width: 1px; -fx-background-color: rgba(0,0,0,0.5); @@ -206,4 +218,4 @@ TextField { -fx-font-size: 10px; -fx-font-family: 'Orbitron'; -fx-fill: white; -} \ No newline at end of file +}