commit d77738eb38781952804830da01672ad9ed2d6664 Author: Matthew Grove Date: Fri Mar 24 15:48:54 2023 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff6309 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/cssdialects.xml b/.idea/cssdialects.xml new file mode 100644 index 0000000..68d4fdd --- /dev/null +++ b/.idea/cssdialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e5d6295 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e4cbf1 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# ECS Chat + +Chat application with built-in game built as part of COMP1206 at the University of Southampton. + +Thanks goes to Oli Bills for building the backend system and template for this software. \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bfabd47 --- /dev/null +++ b/pom.xml @@ -0,0 +1,46 @@ + + 4.0.0 + uk.ac.soton.comp1206 + app + 1.0-SNAPSHOT + + UTF-8 + 11 + 11 + + + + org.openjfx + javafx-controls + 19.0.2.1 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + 11 + + + + org.openjfx + javafx-maven-plugin + 0.0.6 + + + + + default-cli + + uk.ac.soton.comp1206.App + + + + + + + diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..48723d7 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,4 @@ +module uk.ac.soton.comp1206 { + requires javafx.controls; + exports uk.ac.soton.comp1206; +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/App.java b/src/main/java/uk/mgrove/ac/soton/comp1206/App.java new file mode 100644 index 0000000..6715fab --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/App.java @@ -0,0 +1,30 @@ +package uk.ac.soton.comp1206; + +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + + +/** + * JavaFX App + */ +public class App extends Application { + + @Override + public void start(Stage stage) { + var javaVersion = SystemInfo.javaVersion(); + var javafxVersion = SystemInfo.javafxVersion(); + + var label = new Label("Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."); + var scene = new Scene(new StackPane(label), 640, 480); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(); + } + +} \ No newline at end of file diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/SystemInfo.java b/src/main/java/uk/mgrove/ac/soton/comp1206/SystemInfo.java new file mode 100644 index 0000000..67fb4a9 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/SystemInfo.java @@ -0,0 +1,13 @@ +package uk.ac.soton.comp1206; + +public class SystemInfo { + + public static String javaVersion() { + return System.getProperty("java.version"); + } + + public static String javafxVersion() { + return System.getProperty("javafx.version"); + } + +} \ No newline at end of file diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/BaseWindow.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/BaseWindow.java new file mode 100644 index 0000000..74cc18a --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/BaseWindow.java @@ -0,0 +1,252 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.animation.*; +import javafx.beans.property.DoubleProperty; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.SplitPane; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Window; +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.network.Communicator; + +import java.util.Arrays; + +/** + * Base window class, providing template from which all windows should extend + */ +public abstract class BaseWindow extends Window { + protected static final Logger logger = LogManager.getLogger(ChatWindow.class); + + protected final App app; + protected final Communicator communicator; + + /** + * Duration over which menus should slide open/closed + */ + protected final static Duration menuTransitionDuration = Duration.millis(300); + /** + * Duration over which buttons should rotate - used for menu open/close buttons + */ + protected final static Duration buttonRotationDuration = Duration.millis(150); + /** + * Duration over which windows should fade in + */ + protected final static Duration windowFadeInDuration = Duration.millis(500); + + @FXML + protected VBox rightPanel; + @FXML + protected VBox leftPanel; + @FXML + protected SplitPane mainSplitPane; + @FXML + protected ImageView showLeftPanel; + @FXML + protected ImageView showRightPanel; + @FXML + protected ImageView hideLeftPanel; + @FXML + protected ImageView hideRightPanel; + @FXML + protected StackPane mainStackPane; + + protected Parent root; + protected Node rightPanelPane; + protected Node leftPanelPane; + protected double[] mainSplitPaneDividerPositions; + protected boolean[] sidePanelsVisible; + + /** + * Initialise the window with an FXML template + * @param app app to use + * @param communicator communicator to use + * @param fxmlPath path to FXML file + */ + public BaseWindow(App app, Communicator communicator, String fxmlPath) { + + this.app = app; + this.communicator = communicator; + + //Load the Chat Window GUI + try { + //Instead of building this GUI programmatically, we are going to use FXML + var loader = new FXMLLoader(getClass().getResource(fxmlPath)); + + //Link the GUI in the FXML to this class + loader.setController(this); + root = loader.load(); + } catch (Exception e) { + //Handle any exceptions with loading the FXML + logger.error("Unable to read file: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + + root.setOpacity(0); + fadeInWindow(); + + //We are the login window + setScene(new Scene(root)); + + rightPanelPane = mainSplitPane.getItems().get(2); + leftPanelPane = mainSplitPane.getItems().get(0); + mainSplitPaneDividerPositions = mainSplitPane.getDividerPositions(); + sidePanelsVisible = new boolean[2]; + Arrays.fill(sidePanelsVisible, Boolean.TRUE); + mainStackPane.getChildren().removeAll(showLeftPanel, showRightPanel); + + } + + /** + * Fade the root node from invisible to visible + */ + protected void fadeInWindow() { + FadeTransition ft = new FadeTransition(windowFadeInDuration, root); + ft.setFromValue(0); + ft.setToValue(1); + ft.play(); + } + + /** + * Hide a given pane in a ScrollPane - used to hide menus + * + * @param panelPane the pane to hide + * @param dividerPositionProperty the property for the appropriate divider's position + * @param hideButton button to hide the pane + * @param showButton button to show the pane + * @param panelIndex index of the panel in local info stores (e.g. list of open/closed menus) + */ + protected void hidePanel(Pane panelPane, DoubleProperty dividerPositionProperty, Node hideButton, Node showButton, int panelIndex) { + panelPane.setMinWidth(0); + + int finalDividerPosition = panelIndex == 0 ? 0 : 1; + + KeyValue keyValue = new KeyValue(dividerPositionProperty, finalDividerPosition, Interpolator.EASE_BOTH); + Timeline hidePanel = new Timeline(new KeyFrame(menuTransitionDuration, keyValue)); + + RotateTransition rotateButton = new RotateTransition(buttonRotationDuration, hideButton); + rotateButton.setFromAngle(0); + rotateButton.setToAngle(panelIndex == 0 ? 180 : -180); + rotateButton.setOnFinished((event2) -> { + mainStackPane.getChildren().add(showButton); + mainStackPane.getChildren().remove(hideButton); + hideButton.setRotate(0); + }); + + hidePanel.play(); + + hidePanel.setOnFinished((event) -> { + rotateButton.play(); + sidePanelsVisible[panelIndex] = false; + mainSplitPane.getItems().remove(panelPane); + if (panelIndex == 0 && sidePanelsVisible[1]) + mainSplitPane.setDividerPosition(0, mainSplitPaneDividerPositions[1]); + }); + } + + /** + * Show a given pane in a ScrollPane - used to show menus + * + * @param panelPane the pane to show + * @param hideButton button to hide the pane + * @param showButton button to show the pane + * @param panelIndex index of the panel in local info stores (e.g. list of open/closed menus) + */ + protected void showPanel(Pane panelPane, Node hideButton, Node showButton, int panelIndex) { + if (panelIndex == 0) mainSplitPane.getItems().add(0, panelPane); + else mainSplitPane.getItems().add(panelPane); + + DoubleProperty dividerPositionProperty = mainSplitPane.getDividers().get(panelIndex == 1 && !sidePanelsVisible[0] ? 0 : panelIndex).positionProperty(); + + int initialDividerPosition = panelIndex == 0 ? 0 : 1; + + mainSplitPane.setDividerPosition(panelIndex == 1 && !sidePanelsVisible[0] ? 0 : panelIndex, initialDividerPosition); + KeyValue keyValue = new KeyValue(dividerPositionProperty, mainSplitPaneDividerPositions[panelIndex], Interpolator.EASE_BOTH); + Timeline showPanel = new Timeline(new KeyFrame(menuTransitionDuration, keyValue)); + + RotateTransition rotateButton = new RotateTransition(buttonRotationDuration, showButton); + rotateButton.setFromAngle(0); + rotateButton.setToAngle(panelIndex == 0 ? -180 : 180); + rotateButton.setOnFinished((event2) -> { + mainStackPane.getChildren().add(hideButton); + mainStackPane.getChildren().remove(showButton); + showButton.setRotate(0); + }); + + showPanel.play(); + + showPanel.setOnFinished((event1) -> { + rotateButton.play(); + sidePanelsVisible[panelIndex] = true; + panelPane.setMinWidth(Region.USE_COMPUTED_SIZE); + if (panelIndex == 0 && sidePanelsVisible[1]) + mainSplitPane.setDividerPosition(1, mainSplitPaneDividerPositions[1]); + }); + } + + /** + * Toggle the visibility of the right-hand sidebar + */ + protected void toggleRightPanel() { + if (sidePanelsVisible[1]) { + DoubleProperty positionProperty; + if (sidePanelsVisible[0]) { + mainSplitPaneDividerPositions[1] = mainSplitPane.getDividerPositions()[1]; + positionProperty = mainSplitPane.getDividers().get(1).positionProperty(); + } else { + mainSplitPaneDividerPositions[1] = mainSplitPane.getDividerPositions()[0]; + positionProperty = mainSplitPane.getDividers().get(0).positionProperty(); + } + + hidePanel(rightPanel, positionProperty, hideRightPanel, showRightPanel, 1); + } else { + showPanel(rightPanel, hideRightPanel, showRightPanel, 1); + } + } + + /** + * Toggle the visibility of the right-hand sidebar + */ + protected void toggleLeftPanel() { + if (sidePanelsVisible[0]) { + mainSplitPaneDividerPositions[0] = mainSplitPane.getDividerPositions()[0]; + if (sidePanelsVisible[1]) mainSplitPaneDividerPositions[1] = mainSplitPane.getDividerPositions()[1]; + + hidePanel(leftPanel, mainSplitPane.getDividers().get(0).positionProperty(), hideLeftPanel, showLeftPanel, 0); + } else { + if (sidePanelsVisible[1]) mainSplitPaneDividerPositions[1] = mainSplitPane.getDividerPositions()[0]; + showPanel(leftPanel, hideLeftPanel, showLeftPanel, 0); + } + } + + /** + * Handle mouse click to toggle visibility of right-hand sidebar + * + * @param event mouse clicked + */ + public void toggleRightPanel(MouseEvent event) { + toggleRightPanel(); + } + + /** + * Handle mouse click to toggle visibility of right-hand sidebar + * + * @param event mouse clicked + */ + public void toggleLeftPanel(MouseEvent event) { + toggleLeftPanel(); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/ChatWindow.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/ChatWindow.java new file mode 100644 index 0000000..737d9a4 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/ChatWindow.java @@ -0,0 +1,262 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.animation.FadeTransition; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.fxml.FXML; +import javafx.scene.control.*; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.util.Duration; +import uk.mgrove.ac.soton.comp1206.App; +import uk.mgrove.ac.soton.comp1206.network.Communicator; +import uk.mgrove.ac.soton.comp1206.utility.Utility; + +import java.awt.event.ActionEvent; +import java.util.Arrays; + +/** + * Chat window for displaying chat interface + */ +public class ChatWindow extends BaseWindow { + + private final static Duration messageFadeInDuration = Duration.millis(150); + + @FXML + private TextField usersSearchField; + @FXML + private Text myUsernameText; + @FXML + private TextField myUsernameInput; + @FXML + private ScrollPane messagesContainer; + @FXML + private Label noMessagesYetText; + @FXML + private VBox messages; + @FXML + private TextField messageToSend; + @FXML + private CheckBox audioEnabled; + @FXML + private HBox myUsernameInputContainer; + @FXML + private HBox myUsernameTextContainer; + @FXML + private VBox myUsernameContainer; + @FXML + private final UserList userList; + @FXML + private VBox rightPanel; + @FXML + private VBox leftPanel; + @FXML + private SplitPane mainSplitPane; + @FXML + private ImageView showLeftPanel; + @FXML + private ImageView showRightPanel; + @FXML + private ImageView hideLeftPanel; + @FXML + private ImageView hideRightPanel; + @FXML + private StackPane mainStackPane; + @FXML + private Button openGame; + private boolean messagesReceived; + private boolean scrollToBottom; + private boolean lastMessageFromMe; + + /** + * Initialise the chat window + * @param app the app + * @param communicator the communicator + */ + public ChatWindow(App app, Communicator communicator) { + super(app, communicator, "/chat.fxml"); + + //Link the communicator to this window + communicator.setWindow(this); + + getScene().addPostLayoutPulseListener(this::scrollToBottom); + + StringProperty userSearchProperty = new SimpleStringProperty(""); + userList = new UserList(userSearchProperty); + Platform.runLater(() -> rightPanel.getChildren().add(userList)); + usersSearchField.textProperty().bindBidirectional(userSearchProperty); + usersSearchField.setOnKeyReleased((event) -> { + // refresh list of people + userList.updateDisplayedUsers(); + }); + hideMyUsernameInput(false); + + rightPanelPane = mainSplitPane.getItems().get(2); + leftPanelPane = mainSplitPane.getItems().get(0); + mainSplitPaneDividerPositions = mainSplitPane.getDividerPositions(); + sidePanelsVisible = new boolean[2]; + Arrays.fill(sidePanelsVisible, Boolean.TRUE); + mainStackPane.getChildren().removeAll(showLeftPanel, showRightPanel); + + messagesReceived = false; + myUsernameInput.textProperty().bindBidirectional(app.usernameProperty()); + myUsernameText.textProperty().bind(app.usernameProperty()); + audioEnabled.selectedProperty().bindBidirectional(Utility.audioEnabledProperty()); + + openGame.setOnAction((e) -> { + app.openGame(); + }); + + communicator.addListener(this::receiveMessage); + + Platform.runLater(() -> messageToSend.requestFocus()); + } + + /** + * Handle what happens when the user presses enter on the message to send field + * + * @param event key pressed + */ + @FXML + protected void handleUsernameKeypress(KeyEvent event) { + if (event.getCode() != KeyCode.ENTER) return; + // hide input + hideMyUsernameInput(true); + messageToSend.requestFocus(); + } + + /** + * Handle what happens when the user presses enter on the message to send field + * + * @param event key pressed + */ + @FXML + protected void handleMessageToSendKeypress(KeyEvent event) { + if (event.getCode() != KeyCode.ENTER) return; + sendCurrentMessage(messageToSend.getText()); + } + + /** + * Handle what happens when the user presses the send message button + * + * @param event button clicked + */ + @FXML + protected void handleSendMessage(ActionEvent event) { + String message = messageToSend.getText(); + if (message.isBlank()) return; + sendCurrentMessage(message); + } + + /** + * Handle event to show username input when button pressed + * + * @param event button clicked + */ + public void showMyUsernameInput(MouseEvent event) { + showMyUsernameInput(); + } + + /** + * Handle show username input + */ + public void showMyUsernameInput() { + myUsernameContainer.getChildren().remove(myUsernameTextContainer); + myUsernameContainer.getChildren().add(myUsernameInputContainer); + myUsernameInput.requestFocus(); + myUsernameInput.end(); + } + + /** + * Handle event to hide username input when button pressed + * + * @param event button clicked + */ + public void hideMyUsernameInput(MouseEvent event) { + hideMyUsernameInput(true); + } + + /** + * Handle hide username input, with option to only remove input container and not add text container + * + * @param showTextContainer add text container to window + */ + private void hideMyUsernameInput(boolean showTextContainer) { + myUsernameContainer.getChildren().remove(myUsernameInputContainer); + if (showTextContainer) { + myUsernameContainer.getChildren().add(myUsernameTextContainer); + messageToSend.requestFocus(); + } + } + + /** + * Handle an incoming message from the Communicator + * + * @param text The message that has been received, in the form User:Message + */ + public void receiveMessage(String text) { + if (!text.contains(":")) return; + if (!messagesReceived) { + messages.getChildren().remove(noMessagesYetText); + messagesReceived = true; + } + var components = text.split(":"); + if (components.length < 2) return; + var username = components[0]; + var message = String.join(":", Arrays.copyOfRange(components, 1, components.length)); + userList.addUser(username); + + Message receivedMessage = new Message(app, username, message); + receivedMessage.setOpacity(0); + FadeTransition fadeMessage = new FadeTransition(messageFadeInDuration, receivedMessage); + fadeMessage.setToValue(1); + messages.getChildren().add(receivedMessage); + fadeMessage.play(); + + if (messagesContainer.getVvalue() == 1.0f) scrollToBottom = true; + + if (!lastMessageFromMe) Utility.playAudio("incoming.mp3"); + else lastMessageFromMe = false; + } + + /** + * Scroll to bottom of messages list. + */ + private void scrollToBottom() { + if (!scrollToBottom) return; + messagesContainer.setVvalue(1.0); + scrollToBottom = false; + } + + /** + * Send an outgoing message from the Chat window. + * + * @param text The text of the message to send to the Communicator + */ + private void sendCurrentMessage(String text) { + if (!text.isBlank()) { + if (text.strip().equals("/game")) app.openGame(); + else if (text.matches("/nick (.+)$")) app.setUsername(text.substring(6)); + else { + communicator.send(app.getUsername() + ":" + text); + lastMessageFromMe = true; + } + messageToSend.clear(); + } + } + + /** + * Unhide this window + */ + public void show() { + super.show(); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/Message.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/Message.java new file mode 100644 index 0000000..659c370 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/Message.java @@ -0,0 +1,67 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.scene.control.Hyperlink; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import uk.mgrove.ac.soton.comp1206.App; +import uk.mgrove.ac.soton.comp1206.utility.Utility; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; + +/** + * Class for a message, which displays the message in a formatted way + */ +public class Message extends TextFlow { + + private final App app; + private final String username; + private final String message; + private final LocalDateTime received; + private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); + + /** + * Initialise the message with basic info + * @param app the app + * @param username username of user who sent the message + * @param message the message contents + */ + public Message(App app, String username, String message) { + this.app = app; + this.username = username; + this.message = message; + received = LocalDateTime.now(); + build(); + } + + /** + * Build the message + */ + public void build() { + Text timeField = new Text("[" + formatter.format(received) + "] "); + timeField.getStyleClass().add("timestamp"); + Text usernameField = new Text(username + ": "); + usernameField.getStyleClass().add("username"); + getChildren().addAll(timeField,usernameField); + + Matcher urlMatcher = Utility.getUrlPattern().matcher(message); + var previousMatchEnd = 0; + while (urlMatcher.find()) { + if (urlMatcher.start() > previousMatchEnd) { + getChildren().add(new Text(message.substring(previousMatchEnd, urlMatcher.start()))); + } + var url = message.substring(urlMatcher.start(), urlMatcher.end()); + var urlField = new Hyperlink(url); + urlField.setOnAction((event) -> app.getHostServices().showDocument(url)); + getChildren().add(urlField); + previousMatchEnd = urlMatcher.end(); + } + if (message.length() > previousMatchEnd) { + getChildren().add(new Text(message.substring(previousMatchEnd))); + } + + getStyleClass().add("message-container"); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/MessageListener.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/MessageListener.java new file mode 100644 index 0000000..c0aceac --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/MessageListener.java @@ -0,0 +1,15 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +/** + * Interface for listeners that handle new messages being received by the Communicator class + * @see uk.mgrove.ac.soton.comp1206.network.Communicator + */ +public interface MessageListener { + + /** + * Receive a message + * @param message the message to receive + */ + void receiveMessage(String message); + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/User.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/User.java new file mode 100644 index 0000000..5fa5266 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/User.java @@ -0,0 +1,58 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.animation.FadeTransition; +import javafx.beans.binding.Bindings; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.geometry.Insets; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.util.Duration; + +/** + * Class for a user, which is an element to be displayed in the chat window + */ +public class User extends VBox { + + private final String username; + private final IntegerProperty messageCount; + + /** + * Initialise the user with a username + * @param username username of user + */ + public User(String username) { + setSpacing(2); + setPadding(new Insets(0, 12,0,12)); + setFillWidth(true); + this.username = username; + this.messageCount = new SimpleIntegerProperty(1); + var messageCountLabel = new Label(""); + messageCountLabel.getStyleClass().add("italic"); + messageCountLabel.setWrapText(true); + messageCountLabel.textProperty().bind(Bindings.concat(messageCount.asString()," message(s)")); + var usernameLabel = new Label(username); + usernameLabel.setWrapText(true); + getChildren().addAll( + usernameLabel, + messageCountLabel + ); + } + + /** + * Get property for number of messages sent by user + * @return property + */ + public IntegerProperty messageCountProperty() { + return messageCount; + } + + /** + * Get user's username + * @return username + */ + public String getUsername() { + return username; + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/UserList.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/UserList.java new file mode 100644 index 0000000..00d0e0d --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/chat/UserList.java @@ -0,0 +1,81 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.animation.FadeTransition; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.StringProperty; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.VBox; +import javafx.util.Duration; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; + +/** + * List of users to be displayed in chat window + */ +public class UserList extends ScrollPane { + + private final Map userMessageCounts = new HashMap<>(); + private final VBox container; + private final ArrayList userElements = new ArrayList<>(); + private final StringProperty userSearchProperty; + + /** + * Initialise the list of users + * @param userSearchProperty property for content of users search field + */ + public UserList(StringProperty userSearchProperty) { + setVbarPolicy(ScrollBarPolicy.AS_NEEDED); + setHbarPolicy(ScrollBarPolicy.NEVER); + getStyleClass().add("user-list"); + setFitToWidth(true); + container = new VBox(); + container.setSpacing(12); + container.setFillWidth(true); + setContent(container); + this.userSearchProperty = userSearchProperty; + } + + /** + * Add user to list of users + * @param username username of user to add + */ + public void addUser(String username) { + if (userMessageCounts.containsKey(username)) { + var property = userMessageCounts.get(username); + property.set(property.get() + 1); + } else { + var newUser = new User(username); + userMessageCounts.put(username, newUser.messageCountProperty()); + + newUser.setOpacity(0); + FadeTransition fadeMessage = new FadeTransition(Duration.millis(150), newUser); + fadeMessage.setToValue(1); + userElements.add(newUser); + fadeMessage.play(); + + sortUserElements(); + updateDisplayedUsers(); + } + } + + private void sortUserElements() { + userElements.sort(Comparator.comparing(User::getUsername)); + } + + /** + * Update the list of users displayed + */ + public void updateDisplayedUsers() { + if (userSearchProperty.get().isBlank()) { + container.getChildren().setAll(userElements); + } else { + container.getChildren().setAll(userElements.stream().filter((user) -> user.getUsername().toLowerCase().contains(userSearchProperty.get().toLowerCase())).toList()); + } + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/BlockClickedListener.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/BlockClickedListener.java new file mode 100644 index 0000000..18ddf63 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/BlockClickedListener.java @@ -0,0 +1,14 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +/** + * Interface for listeners that handle a game block being clicked + */ +public interface BlockClickedListener { + + /** + * Handle game block being clicked + * @param block block that was clicked + */ + void blockClicked(GameBlock block); + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameBlock.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameBlock.java new file mode 100644 index 0000000..2d10140 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameBlock.java @@ -0,0 +1,114 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Class for a game block, which is a single block with a colour that can be changed + */ +public class GameBlock extends Canvas { + + private final int x; + private final int y; + private final Color[] colours; + private final double width; + private final double height; + private final IntegerProperty value; + private final boolean showStrokes; + private final boolean roundedCorners; + + + /** + * Initialise the game block with a set of available colours, a set size, + * a set location in the grid, and outlines around the block + * @param colours available colours + * @param x column index of location in grid + * @param y row index of location in grid + * @param width block width + * @param height block height + */ + public GameBlock(Color[] colours, int x, int y, double width, double height) { + this(colours,x,y,width,height,true,false); + } + + /** + * Initialise the game block with a set of available colours, a set size, + * a set location in the grid, and optional outlines around the block + * @param colours available colours + * @param x column index of location in grid + * @param y row index of location in grid + * @param width block width + * @param height block height + * @param showStrokes whether block should have an outline + * @param roundedCorners whether block should have rounded corners + */ + public GameBlock(Color[] colours, int x, int y, double width, double height, boolean showStrokes, boolean roundedCorners) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.colours = colours; + this.showStrokes = showStrokes; + this.roundedCorners = roundedCorners; + value = new SimpleIntegerProperty(); + + setWidth(width); + setHeight(height); + + value.addListener(((observableValue, oldValue, newValue) -> paint())); + + paint(); + } + + /** + * Paint the block - i.e. draw it + */ + public void paint() { + GraphicsContext gc = getGraphicsContext2D(); + gc.clearRect(0,0,width,height); + + if (value.get() >= 0) { + // fill in colour block + gc.setFill(colours[value.get()]); + gc.setStroke(Color.BLACK); + + if (roundedCorners) { + gc.fillRoundRect(0,0,width,height,16,16); + if (showStrokes) gc.strokeRoundRect(0,0,width,height,16,16); + } else { + gc.fillRect(0,0,width,height); + if (showStrokes) gc.strokeRect(0,0,width,height); + } + } + } + + /** + * Get the x-coordinate of this block in the grid + * @return x-coordinate + */ + public int getX() { + return x; + } + + /** + * Get the y-coordinate of this block in the grid + * @return y-coordinate + */ + public int getY() { + return y; + } + + /** + * Get the value property for binding, which holds the current colour value + * @return value property + */ + public IntegerProperty valueProperty() { + return value; + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameGrid.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameGrid.java new file mode 100644 index 0000000..0f0f7b1 --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameGrid.java @@ -0,0 +1,63 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.scene.layout.GridPane; +import javafx.scene.paint.Color; + +import java.util.ArrayList; +import java.util.List; + +/** + * Game grid class, which displays a grid of game blocks for the game + */ +public class GameGrid extends GridPane { + + private final List listeners = new ArrayList<>(); + + /** + * Initialise game grid with an existing grid, a set size, and a set of available colours + * @param colours available colours + * @param grid existing grid to use + * @param width game grid width + * @param height game grid height + */ + public GameGrid(Color[] colours, Grid grid, int width, int height) { + var cols = grid.getCols(); + var rows = grid.getRows(); + + setMaxWidth(width); + setMaxHeight(height); + setGridLinesVisible(true); + + // create each internal game block + for (var y=0; y < rows; y++) { + for (var x=0; x < cols; x++) { + var block = new GameBlock(colours, x, y, (double) width/cols, (double) height/rows); + add(block,x,y); + + block.valueProperty().bind(grid.getGridProperty(x,y)); + + // handle when game block is clicked + block.setOnMouseClicked((e) -> blockClicked(block)); + } + } + } + + /** + * Add listener for blocks being clicked + * @param listener listener + */ + public void addListener(BlockClickedListener listener) { + this.listeners.add(listener); + } + + /** + * Handle block being clicked by calling all appropriate listeners + * @param block block that was clicked + */ + public void blockClicked(GameBlock block) { + for (BlockClickedListener listener : listeners) { + listener.blockClicked(block); + } + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameWindow.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameWindow.java new file mode 100644 index 0000000..23ded6e --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/GameWindow.java @@ -0,0 +1,238 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.SplitPane; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.text.Text; +import javafx.stage.Stage; +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.network.Communicator; +import uk.mgrove.ac.soton.comp1206.utility.Utility; + +import java.util.*; + +/** + * Game window which will display mini-game for users to play + */ +public class GameWindow extends BaseWindow { + + private static final Logger logger = LogManager.getLogger(GameWindow.class); + + private static final int maxColourChanges = 30; + + private final Grid grid; + private final Random random = new Random(); + private final Color[] colours; + private final IntegerProperty currentColour = new SimpleIntegerProperty(); + private final IntegerProperty score = new SimpleIntegerProperty(0); + private Timer gameTimer; + private int colourChangesCount; + private final List scores = new ArrayList<>(); + @FXML + private BorderPane gameBoardContainer; + @FXML + private Text currentScoreField; + @FXML + private VBox currentColourContainer; + @FXML + private VBox highScoresContainer; + @FXML + private VBox gameCoverContainer; + @FXML + private Text gameCoverTitle; + @FXML + private ProgressBar progressBar; + + /** + * Initialise the game window + * + * @param app the app + * @param communicator the communicator + */ + public GameWindow(App app, Communicator communicator) { + super(app, communicator, "/game.fxml"); + + colours = new Color[]{ + Color.web("0x005C84"), + Color.web("0x74C9E5"), + Color.web("0x4BB694"), + Color.web("0xC1D100"), + Color.web("0xEF7D00"), + Color.web("0xE73037"), + Color.web("0xD5007F"), + Color.web("0x8D3970") + }; + + grid = new Grid(6, 6); + + currentScoreField.textProperty().bind(score.asString()); + + var currentColourBlock = new GameBlock(colours, 0, 0, 100, 100, false, true); + currentColourContainer.getChildren().add(currentColourBlock); + currentColourBlock.valueProperty().bind(currentColour); + + communicator.addListener((message) -> { + if (!message.startsWith("SCORES")) return; + Platform.runLater(() -> this.receiveScore(message)); + }); + + communicator.send("SCORES"); + } + + /** + * Events that occur at regular intervals to maintain the game: update the current colour and handle game ending + */ + public void gameLoop() { + if (colourChangesCount < maxColourChanges) { + var randomColour = random.nextInt(colours.length); + logger.info("Game loop - Colour chosen: {}", randomColour); + currentColour.set(randomColour); + colourChangesCount++; + progressBar.setProgress((double) (maxColourChanges - colourChangesCount + 1) / maxColourChanges); + } else { + progressBar.setProgress(0); + endGame(); + } + } + + /** + * Handle game block being clicked - adjust score appropriately + * + * @param block block that was clicked + */ + public void blockClicked(GameBlock block) { + var gridBlock = grid.getGridProperty(block.getX(), block.getY()); + if (currentColour.get() == gridBlock.get()) { + logger.info("{} is {}, scoring 5 points", currentColour.get(), gridBlock.getValue()); + score.set(score.get() + 5); + gridBlock.set(random.nextInt(colours.length)); + } else { + logger.info("{} is not {}, losing 1 point", currentColour.get(), gridBlock.getValue()); + if (score.get() > 0) { + score.set(score.get() - 1); + } + } + } + + /** + * Receive and store scores from server + * @param message message containing scores + */ + private void receiveScore(String message) { + scores.clear(); + + for (var line : message.split("\n")) { + if (!line.equals("SCORES")) { + int splitIndex = line.lastIndexOf("="); + try { + String[] info = {line.substring(0,splitIndex), line.substring(splitIndex+1)}; + scores.add(info[0] + ": " + info[1]); + } catch (Exception ignored) { + logger.error("Couldn't load score from message {}",message); + } + } + } + + highScoresContainer.getChildren().clear(); + for (var i=0; i < Math.min(scores.size(),5); i++) { + highScoresContainer.getChildren().add(new Text(scores.get(i))); + } + + } + + /** + * Show game over screen + */ + private void showGameOver() { + gameCoverTitle.setText("Game Over!"); + Platform.runLater(() -> gameBoardContainer.setCenter(gameCoverContainer)); + // TODO: include a timer bar + } + + /** + * Start a new game + */ + private void startGame() { + colourChangesCount = 0; + score.set(0); + + // set up grid with random values + for (int y = 0; y < grid.getRows(); y++) { + for (int x = 0; x < grid.getCols(); x++) { + grid.set(x, y, random.nextInt(colours.length)); + } + } + + currentColour.set(random.nextInt(colours.length)); + + var gameGrid = new GameGrid(colours, grid, 400, 350); + gameGrid.addListener(this::blockClicked); + + gameBoardContainer.setCenter(gameGrid); + + TimerTask timerTask = new TimerTask() { + @Override + public void run() { + gameLoop(); + } + }; + gameTimer = new Timer("Timer"); + gameTimer.schedule(timerTask, 0, 2000); + } + + /** + * Start game when button clicked + * @param event button clicked + */ + public void startGame(ActionEvent event) { + startGame(); + } + + /** + * Return to the chat window when button clicked + * @param event button clicked + */ + public void returnToChat(ActionEvent event) { + stopGameTimer(); + app.setGameOpen(false); + app.returnToChat(this); + } + + /** + * Stop the game + */ + private void endGame() { + // game finished + stopGameTimer(); + currentColour.set(-1); + communicator.send(String.format("SCORE %s %d",app.getUsername(),score.get())); + communicator.send("SCORES"); + showGameOver(); + } + + /** + * Stop the game timer + */ + public void stopGameTimer() { + if (gameTimer != null) gameTimer.cancel(); + } + +} diff --git a/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/Grid.java b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/Grid.java new file mode 100644 index 0000000..129f29c --- /dev/null +++ b/src/main/java/uk/mgrove/ac/soton/comp1206/ui/game/Grid.java @@ -0,0 +1,79 @@ +package uk.mgrove.ac.soton.comp1206.ui; + +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; + +/** + * Grid class, used to store a grid of game blocks for the game + */ +public class Grid { + + private final int cols; + private final int rows; + final SimpleIntegerProperty[][] grid; + + /** + * Initialise the grid + * @param cols number of columns in the grid + * @param rows number of rows in the grid + */ + public Grid(int cols, int rows) { + this.cols = cols; + this.rows = rows; + + grid = new SimpleIntegerProperty[cols][rows]; + + for (var y=0; y < rows; y++) { + for (var x=0; x < cols; x++) { + grid[x][y] = new SimpleIntegerProperty(0); + } + } + } + + /** + * Get the property relating to a specific element in the grid + * @param x column index + * @param y row index + * @return the property relating to the element at the given location + */ + public IntegerProperty getGridProperty(int x, int y) { + return grid[x][y]; + } + + /** + * Set the value of a specific element in the grid + * @param x column index + * @param y row index + * @param value the value to set + */ + public void set(int x, int y, int value) { + grid[x][y].set(value); + } + + /** + * Get the value of a specific element in the grid + * @param x column index + * @param y row index + * @return the value of the element + */ + public int get(int x, int y) { + return grid[x][y].get(); + } + + /** + * Get number of columns in the grid + * @return number of columns + */ + public int getCols() { + return cols; + } + + /** + * Get number of rows in the grid + * @return number of rows + */ + public int getRows() { + return rows; + } + +} diff --git a/src/main/resources/base.fxml b/src/main/resources/base.fxml new file mode 100644 index 0000000..cfa4f0b --- /dev/null +++ b/src/main/resources/base.fxml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/global.css b/src/main/resources/global.css new file mode 100644 index 0000000..3db9403 --- /dev/null +++ b/src/main/resources/global.css @@ -0,0 +1,93 @@ +/* global */ +root { + display: block; +} +.root > * { + -fx-padding: 4px; + -fx-background-color:#ffffff; +} +* { + -fx-font-family: "Roboto"; +} + +/* menus */ +.menu { + -fx-padding: 32px 0 0 0; +} +.menu > .button { + -fx-text-alignment: left; + -fx-alignment: top-left !important; +} + +/* checkboxes */ +.check-box > .box { + -fx-background-color: transparent; + -fx-border-width: 1px; + -fx-border-color: #9FB1BD; + -fx-border-radius: 4px; +} +.check-box:selected > .box { + -fx-background-color: #E1E8EC; +} +.check-box:selected > .box > .mark, +.check-box:indeterminate > .box > .mark { + -fx-background-color: #005C84; +} + +/* buttons and text fields */ +.button { + -fx-text-fill: #2a2a2a; + -fx-font-family: "Arial"; + -fx-font-weight: bold; + -fx-text-background-color: #2a2a2a; + -fx-background-color: transparent; + -fx-border-width: 1px; + -fx-border-color: #E1E8EC; + -fx-border-radius: 8px; + -fx-background-radius: 8px; +} +.button.active { + -fx-background-color: #E1E8EC; + -fx-text-background-color: #005C84; + -fx-text-fill: #005C84; + -fx-border-color: #758D9A; +} +.button-primary { + -fx-background-color: #005C84; + -fx-text-background-color: #fff; + -fx-text-fill: #fff; + -fx-border-color: #005C84; +} +.text-field, .button { + -fx-padding: 12px; +} +.text-field { + -fx-background-color: transparent; + -fx-border-width: 1px; + -fx-border-color: #cccccc; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-padding: 12px; +} + +/* headings */ +.h2 { + -fx-font-size: 2em; +} +.h3 { + -fx-font-size: 1.4em; +} + +/* headers */ +.header .logo { + -fx-start-margin: 8px; + -fx-end-margin: 8px; +} + +/* sections */ +.outline-section { + -fx-border-width: 1px; + -fx-border-color: #E1E8EC; + -fx-border-radius: 8px; + -fx-padding: 12px; +}