diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1125369 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Auto detect text files and perform LF normalization +* text=auto +*.png binary +*.pdf binary +*.pptx binary +*.mp4 binary +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96321cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,232 @@ +desktop.ini +test.ini +highscores.txt + +.gradle/ +.idea/ +.vscode/ +build/ +bin/ + +*.class +*.bin +*.log +*.jar + + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt new file mode 100644 index 0000000..0dbb19c --- /dev/null +++ b/CONTRIBUTORS.txt @@ -0,0 +1,4 @@ +Dario Manser +Elagkian Rajendram +Leandro Lika +Nillan Sivarasa diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff11f83 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +![](src/main/resources/misc/logo.png) + +# Let Us Cook! + +*Let Us Cook!* is an [*Overcooked*](https://ghosttowngames.com/overcooked/)-inspired realtime multiplayer Java game where players work together to prepare and deliver as many restaurant orders as possible during each two-minute round. + +![](img/screenshot.png) + +The game was created by Dario Manser, Elagkian Rajendram, Leandro Lika, and Nillan Sivarasa between March and May 2024 as part of the second semester of our Bachelor's degree of Computer Science at University of Basel, Switzerland. + +## Playing + +First, grab the latest pre-built JAR from the [releases](https://github.com/cookkings/letuscook/releases) or [build it yourself](#building). Once you have the JAR, you can print its command line usage by running `java -jar `, where `` is the path to your JAR file. + +To play the game, you'll need to start a server instance and at least one client. + +For example, you can spin up a local server on port `9999` like so: + +``` +java -ea -jar server 9999 +``` + +Then, in a different terminal, you can start a client to connect to your local server like so: + +``` +java -ea -jar client localhost:9999 +``` + +If you want to connect to your server from other machines (or even from outside your network), you can configure your network accordingly **or use a solution such as [Tailscale](https://tailscale.com/)**, which is what we used. In that case, remember to replace `localhost` with your host's IP address when starting the clients. + +## Building + +- Install [Gradle](https://gradle.org/) and a JDK of your choice (**ensure that your JDK includes the JavaFX runtime – we recommend the [Liberica Full JDK 17](https://bell-sw.com/pages/downloads/#jdk-17-lts)**). +- Run `gradle clean jar` in the root directory. You'll find the resulting JAR in `build/libs`. + +## Testing + +- To run the unit tests, use `gradle test`. +- If you wish to make use of the `test.cmd` or `test` scripts, you'll need to create a `test.ini` file in the root directory with the following contents: + ``` + testcmd_jar=build/libs/letuscook-0.1.0.jar + ``` + **Make sure to adjust the version number if it doesn't match.** Also, know that **`test.cmd` requires [Windows Terminal](https://www.microsoft.com/store/productId/9N0DX20HK701)** and that **`test` only works on macOS**, since it uses AppleScript. + +## Contact + +You can reach us via email: + +- Dario Manser · d.manser@stud.unibas.ch +- Elagkian Rajendram · elagkian.rajendram@stud.unibas.ch +- Leandro Lika · leandro.lika@stud.unibas.ch +- Nillan Sivarasa · nillan.sivarasa@stud.unibas.ch diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7597063 --- /dev/null +++ b/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'java-library' + id 'application' + id 'org.openjfx.javafxplugin' version '0.1.0' + id "org.sonarqube" version "4.4.1.3373" + id 'jacoco' +} + +javafx { + version = "22.0.1" + modules = ['javafx.controls', 'javafx.fxml'] +} + +group 'ch.unibas.dmi.dbis' +version '0.1.0' +mainClassName = 'ch.unibas.dmi.dbis.cs108.letuscook.Main' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.apache.logging.log4j:log4j-api:2.+' + implementation 'org.apache.logging.log4j:log4j-core:2.+' + implementation 'org.openjfx:javafx-controls:21.0.2' + + implementation 'org.openjfx:javafx-media:21.0.2' + + testImplementation("org.junit.jupiter:junit-jupiter:5.+") +} + +test { + useJUnitPlatform() + //finalizedBy jacocoTestReport + + testLogging { + events "passed", "skipped", "failed" + } +} + +//jacoco { +// toolVersion = "0.8.5" +//} + +jar { + manifest { + attributes( + 'Main-Class': mainClassName + ) + } + from { + configurations.compileClasspath.collect { it.isDirectory() ? it : zipTree(it) } + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + duplicatesStrategy(DuplicatesStrategy.INCLUDE) +} + +task "build-cs108"(dependsOn: ['javadoc', 'compileJava', 'jar']) { +} diff --git a/img/screenshot.png b/img/screenshot.png new file mode 100644 index 0000000..4484602 Binary files /dev/null and b/img/screenshot.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b89828e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'letuscook' + diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/Main.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/Main.java new file mode 100644 index 0000000..509af32 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/Main.java @@ -0,0 +1,242 @@ +package ch.unibas.dmi.dbis.cs108.letuscook; + +import ch.unibas.dmi.dbis.cs108.letuscook.cli.ClientCLI; +import ch.unibas.dmi.dbis.cs108.letuscook.cli.ServerCLI; +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.gui.ClientGUI; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Server; +import ch.unibas.dmi.dbis.cs108.letuscook.util.FatalExceptionHandler; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Messenger; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import javafx.application.Application; + +/** + * The Main class starts either a server or a client for the LetUsCook application based on the + * command line arguments. + */ +public class Main { + + /** + * The duration in milliseconds to wait before checking if a pinged connection responded with a + * pong. + */ + public static final long PONG_WAIT_MS; + + /** + * The command line options for the application. + */ + private static final String USAGE = + """ + Usage: + - server + - client
: [|$] + If "$" is supplied as the nickname, the system name is used."""; + + /** + * The contents of the 'test.ini' file, if it exists. Used for testing during development. + */ + private static final List TEST_INI = new ArrayList<>(); + + /* + * Initialization. + */ + static { + for (String[] entry : new String[][]{ + {"src_pong_wait_ms", "15000"}, + {"src_enable_client_gui", "1"}, + {"src_clientcli_startup_actions_delay", "250"}, + {"src_clientcli_startup_actions", ""}, + }) { + setTestingConfigurationEntry(entry); + } + + parseTestingConfiguration(); + + PONG_WAIT_MS = Long.parseLong(getTestingConfigurationEntry("src_pong_wait_ms")[1]); + } + + /** + * The main method is the entry point of the program, which starts either a server or a client. + * + * @param args The command line arguments specifying the startup mode and optionally other + * required parameters. + */ + public static void main(String[] args) { + Thread.setDefaultUncaughtExceptionHandler(new FatalExceptionHandler()); + + if (args.length < 1) { + Main.printUsageAndDie(); + } + + /* + * Server. + */ + if (args[0].equalsIgnoreCase("server")) { + if (args.length < 2) { + Main.printUsageAndDie("Missing server port"); + } + int port = 0; + try { + port = Main.parsePort(args[1]); + } catch (NumberFormatException e) { + Main.printUsageAndDie("Invalid port"); + } + + Messenger.selectLogger("server"); + new Server(port); + ServerCLI.awaitAndConsumeActions(); + return; + } + + /* + * Client. + */ + if (args[0].equalsIgnoreCase("client")) { + if (args.length < 2) { + Main.printUsageAndDie("Missing server address"); + } + String[] addressPortTokens = args[1].split(":"); + if (addressPortTokens.length != 2) { + Main.printUsageAndDie("Invalid format of address"); + } + + InetAddress address = null; + int port = 0; + try { + address = InetAddress.getByName(addressPortTokens[0]); + port = Main.parsePort(addressPortTokens[1]); + } catch (UnknownHostException | SecurityException | NumberFormatException e) { + Main.printUsageAndDie("Unknown or disallowed server address and port"); + } + + Messenger.selectLogger("client"); + + new Client(address, port); + + if (args.length >= 3) { + try { + Client.the().setLoginNickname(SanitizedName.createOrThrow( + args[2].equals("$") ? System.getProperty("user.name") : args[2])); + } catch (MalformedException e) { + Messenger.warn("Ignoring malformed login nickname."); + } + } + + if (Main.getTestingConfigurationEntry("src_enable_client_gui")[1].equals("1")) { + new Thread(() -> Application.launch(ClientGUI.class, args), + "luc-jfx-launcher").start(); + } + + var delay = Main.getTestingConfigurationEntry("src_clientcli_startup_actions_delay"); + String[] actions = Main.getTestingConfigurationEntry("src_clientcli_startup_actions"); + for (int i = 1; i < actions.length; ++i) { + if (!actions[i].isBlank()) { + Messenger.warn("(test.ini) Simulating: " + actions[i]); + ClientCLI.consumeAction(actions[i]); + ClientCLI.consumeAction("/wait " + delay[1]); + } + } + + ClientCLI.awaitAndConsumeActions(); + + return; + } + + Main.printUsageAndDie(); + } + + /** + * Parses the port from the given string. + * + * @param port The port as a string. + * @return The parsed port as an integer. + * @throws NumberFormatException If the string cannot be parsed as an integer. + */ + private static int parsePort(String port) throws NumberFormatException { + return Integer.parseInt(port); + } + + /** + * Prints the instructions for using the LetUsCook application and exits the program. + * + * @param message An optional error message to be displayed. + */ + public static void printUsageAndDie(String message) { + System.err.println("Error: " + message + "\n"); + + Main.printUsageAndDie(); + } + + /** + * Prints the instructions for using the LetUsCook application and exits the program. + */ + public static void printUsageAndDie() { + System.err.println(Main.USAGE); + + System.exit(1); + } + + /** + * Parse the 'test.ini' file, if it exists. + */ + private static void parseTestingConfiguration() { + try { + var reader = new BufferedReader(new FileReader("test.ini")); + String line; + while ((line = reader.readLine()) != null) { + var keyAndValues = line.split("=", 2); + if (keyAndValues.length != 2) { + throw new MalformedException("Missing value"); + } + var key = keyAndValues[0]; + var values = + keyAndValues[1].isEmpty() ? new String[]{} : keyAndValues[1].split(";"); + var entry = new String[1 + values.length]; + entry[0] = key; + System.arraycopy(values, 0, entry, 1, values.length); + Main.setTestingConfigurationEntry(entry); + } + reader.close(); + } catch (FileNotFoundException ignored) { + } catch (MalformedException e) { + System.err.println("Malformed test.ini: " + e.getMessage()); + } catch (IOException e) { + System.err.println("An IOException occurred while reading test.ini"); + } + } + + /** + * Retrieve an entry from the testing configuration. + * + * @param key the key. + * @return the entry. + */ + public static String[] getTestingConfigurationEntry(String key) { + for (var entry : Main.TEST_INI) { + if (entry[0].equals(key)) { + return entry; + } + } + + return new String[]{key}; + } + + /** + * Set an entry in the testing configuration. + * + * @param newEntry the entry. + */ + private static void setTestingConfigurationEntry(String[] newEntry) { + Main.TEST_INI.removeIf(entry -> entry[0].equals(newEntry[0])); + Main.TEST_INI.add(newEntry); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/ClientCLI.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/ClientCLI.java new file mode 100644 index 0000000..512ec5a --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/ClientCLI.java @@ -0,0 +1,198 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.cli; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.gui.Units; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Game; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Player; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Coords; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Messenger; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; +import java.io.IOException; + +public class ClientCLI { + + /** + * Consume a client action. + * + * @param action the client action. + * @return whether the caller should continue awaiting actions after this action is consumed. + */ + public static boolean consumeAction(String action) { + /* + * Filter. + */ + if (action.startsWith("#")) { + var filterAndAction = action.trim().split("\\s+", 2); + if (!filterAndAction[0].substring(1) + .equals(Client.the().getOwnIdentifier().toString())) { + return true; + } + action = filterAndAction[1]; + } + + /* + * Parse action. + */ + var keywordAndArguments = action.trim().split("\\s+", 2); + var keyword = keywordAndArguments[0]; + var argumentString = keywordAndArguments.length == 2 ? keywordAndArguments[1] : ""; + var arguments = argumentString.split("\\s+", 2); + String[] argumentList = {arguments[0], arguments.length == 2 ? arguments[1] : ""}; + + /* + * Slash actions. + */ + if (keyword.startsWith("/")) { + switch (keyword) { + case "/clear" -> Console.clear(); + case "/conn" -> { + if (!argumentString.trim().isEmpty()) { + try { + Client.the().setLoginNickname( + SanitizedName.createOrThrow(argumentString)); + } catch (MalformedException e) { + Messenger.warn("Ignoring malformed login nickname."); + } + } + Client.the().tryConnect(); + } + case "/disc" -> Client.the().tryDisconnect(); + case "/nick" -> Client.the().tryNickname(argumentString); + case "/refresh" -> Client.the().tryRefresh(); + case "/lobbies" -> { + var lobbies = Client.the().getLobbies(); + StringBuilder sb = new StringBuilder("Lobbies: "); + for (var lobby : lobbies) { + sb.append("\n- ").append(lobby.getName()).append(" (") + .append(lobby.gameIsRunning() ? "in-game" : "idle").append("): "); + var members = lobby.getActors(); + synchronized (members) { + for (int i = 0; i < members.length; ++i) { + sb.append(members[i].record().orElseThrow().getNickname()); + if (i < members.length - 1) { + sb.append(", "); + } + } + } + } + Messenger.info(sb.toString()); + } + case "/all" -> { + var actors = Client.the().getActors(); + StringBuilder sb = new StringBuilder("All: "); + synchronized (actors) { + for (var actor : actors) { + sb.append(actor.record().orElseThrow().getNickname()).append(", "); + } + } + Messenger.info(sb.substring(0, sb.length() - 2)); + } + case "/here" -> { + if (!Client.the().hasOwnActor()) { + Messenger.error("Not logged in"); + break; + } + var actor = Client.the().getOwnActor(); + if (actor.member().isEmpty()) { + Messenger.error("Not a member of a lobby"); + break; + } + var lobby = actor.member().orElseThrow().getLobby(); + var members = lobby.getActors(); + StringBuilder sb = new StringBuilder("Here: "); + synchronized (members) { + for (var member : members) { + sb.append(member.member().orElseThrow().player().isEmpty() ? "?" : "") + .append(member.record().orElseThrow().getNickname()).append(", "); + } + } + Messenger.info(sb.substring(0, sb.length() - 2)); + } + case "/open" -> Client.the().tryOpenLobby(argumentString); + case "/join" -> Client.the().tryJoinLobby(argumentString); + case "/leave" -> Client.the().tryLeaveLobby(); + case "/ready" -> Client.the().sendReady(); + case "/start" -> Client.the().tryStartGame(); + case "/yell" -> Client.the().tryYell(argumentString); + case "/die" -> System.exit(1); + case "/quit" -> { + if (Client.the().hasConnection()) { + ClientCLI.consumeAction("/disc"); + } + return false; + } + case "/wait" -> { + try { + Thread.sleep(Integer.parseInt(argumentString)); + } catch (NumberFormatException e) { + Messenger.error("Malformed millisecond amount"); + } catch (InterruptedException e) { + Messenger.warn("Interrupted"); + } + } + case "/pos" -> { + if (argumentString.isBlank()) { + Messenger.info( + Client.the().getOwnActor().member().orElseThrow().player().orElseThrow() + .getRect().toString()); + } else { + try { + Coords coords = new Coords(argumentString); + Game game = Client.the().getOwnActor().member().orElseThrow().getLobby() + .game().orElseThrow(); + Player player = Client.the().getOwnActor().member().orElseThrow() + .player().orElseThrow(); + game.movePlayerBy(player, + new Units(coords.getX().u() - player.getRect().getX().u()), + new Units(coords.getY().u() - player.getRect().getY().u())); + } catch (MalformedException e) { + Messenger.error("Ignoring malformed Position"); + } + } + } + case "/act" -> Client.the().tryInteract(); + case "/hand" -> Messenger.info( + "Hand: " + Client.the().getOwnActor().member().orElseThrow().player() + .orElseThrow().getHolding().toString()); + default -> Messenger.error("Unknown slash action"); + } + return true; + } + + /* + * Whispering. + */ + if (keyword.startsWith("@")) { + Client.the().tryWhisperViaNickname(keyword.substring(1), argumentString); + return true; + } + + /* + * Chat. + */ + Client.the().tryChat(action); + return true; + } + + /** + * Await and consume client actions. + */ + public static void awaitAndConsumeActions() { + Messenger.info("Your login nickname is '" + Client.the().getLoginNickname() + + "'. Use /conn to use a different login nickname."); + + /* + * Await and consume actions. + */ + String action; + try { + do { + action = Console.readln(); + } while (ClientCLI.consumeAction(action)); + } catch (IOException e) { + Messenger.fatal(e, + "An IO error occurred while reading from the console - quitting"); + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/Console.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/Console.java new file mode 100644 index 0000000..aa79171 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/Console.java @@ -0,0 +1,79 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.cli; + +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.NoSuchElementException; +import java.util.Scanner; + +/** + * Helpers for reading from and writing to the console with a custom layout. + */ +public class Console { + + private static final boolean DEBUG_DEACTIVATE = false; + + /** + * A scanner to read console input. + */ + private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8); + + /* + * Initialize the console. + */ + static { + /* Write UTF-8. */ + System.setOut(new PrintStream(new FileOutputStream(FileDescriptor.out), true, + StandardCharsets.UTF_8)); + + Console.clear(); + } + + /** + * Read a line from the console. + * + * @return the line read from the console. + * @throws IOException if an I/O error occurs. + */ + public static String readln() throws IOException { + /* Move cursor to text input line. */ + System.out.print("\033[H\033[K\033[2B\033[K\033[A > \033[K"); + + String input; + try { + input = scanner.nextLine(); + } catch (NoSuchElementException | IllegalStateException e) { + input = null; + } + + /* Move cursor back to previous position. */ + System.out.print("\0338"); + + if (input == null) { + throw new IOException(); + } + return input; + } + + /** + * Print a string to the console, followed by a line break. + * + * @param string the string to print. + */ + public static synchronized void println(String string) { + if (!DEBUG_DEACTIVATE) { + System.out.print("\0338" + string + "\n\0337\033[2;4H"); + } + } + + /** + * Clear the console. + */ + public static synchronized void clear() { + if (!DEBUG_DEACTIVATE) { + System.out.print("\033[2J\033[4H\0337"); + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/ServerCLI.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/ServerCLI.java new file mode 100644 index 0000000..3c2ab5e --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/cli/ServerCLI.java @@ -0,0 +1,58 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.cli; + +import ch.unibas.dmi.dbis.cs108.letuscook.server.Server; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Messenger; +import java.io.IOException; + +public class ServerCLI { + + /** + * Consume a server action. + * + * @param action the server action. + * @return whether the caller should continue awaiting actions after this action is consumed. + */ + public static boolean consumeAction(String action) { + switch (action) { + case "clear" -> Console.clear(); + case "start" -> Server.the().start(); + case "stop" -> Server.the().stop(); + case "inspect" -> { + // TODO: + } + case "quit" -> { + return false; + } + } + + return true; + } + + /** + * Await and consume server actions. + */ + public static void awaitAndConsumeActions() { + /* + * Start-up actions. + */ + ServerCLI.consumeAction("start"); + + /* + * Await and consume actions. + */ + String action; + try { + do { + action = Console.readln(); + } while (ServerCLI.consumeAction(action)); + } catch (IOException e) { + Messenger.fatal(e, + "An IO error occurred while reading from the console - quitting"); + } + + /* + * Shut-down actions. + */ + ServerCLI.consumeAction("stop"); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/client/Client.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/client/Client.java new file mode 100644 index 0000000..3c48f66 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/client/Client.java @@ -0,0 +1,959 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.client; + +import ch.unibas.dmi.dbis.cs108.letuscook.Main; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.ChatCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.Command; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.DisappearCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.GameParticipateCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.GameRequestStartCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.GameScoreCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.GameTimeCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.GameUpdateWorkbenchCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.HighscoresCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.IntroduceCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.LobbyCloseCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.LobbyJoinCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.LobbyLeaveCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.LobbyOpenCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.LobbyReadyCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.PlayerHoldingCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.PlayerInteractCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.PlayerPositionCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.RefreshCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.commands.YellCommand; +import ch.unibas.dmi.dbis.cs108.letuscook.gui.ClientGUI; +import ch.unibas.dmi.dbis.cs108.letuscook.gui.View; +import ch.unibas.dmi.dbis.cs108.letuscook.gui.Views; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.ChoppingWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.CustomerWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.State; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.TransformerWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Workbench; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Actor; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Highscores; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Lobby; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Member; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Player; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Record; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Connection; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Coords; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Messenger; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Schedule; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.io.IOException; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +public class Client { + + private static volatile Client the; + + /** + * The address we connect to. + */ + private final InetAddress address; + + /** + * The port we connect to. + */ + private final int port; + + /** + * All known actors. + */ + private final List actors = Collections.synchronizedList(new ArrayList<>()); + + /** + * All known lobbies. + */ + private final List lobbies = Collections.synchronizedList(new ArrayList<>()); + + /** + * The login nickname. + */ + private volatile SanitizedName loginNickname = SanitizedName.createOrUseFallback( + System.getProperty("user.name"), SanitizedName.createUnsafe("user")); + + /** + * The {@link Connection} we use to control our actor on the server. + */ + private Connection connection; + + /** + * Our own {@link Identifier}. + */ + private Identifier identifier = Identifier.NONE; + + /** + * Periodically check that the connection is still intact. + */ + private Schedule heartbeat; + + /** + * The highscores. + */ + private Highscores highscores = new Highscores(new ArrayList<>()); + + /** + * Denotes if the debug mode is active. + */ + private boolean debugMode = false; + + /** + * Create a client. + * + * @param address the address to connect to on {@link #tryConnect()}. + * @param port the port to connect to on {@link #tryConnect()}. + */ + public Client(InetAddress address, int port) { + assert Client.the == null; + assert address != null; + + this.address = address; + this.port = port; + + Client.the = this; + } + + public static Client the() { + assert Client.the != null : "client not initialized"; + + return Client.the; + } + + /** + * @return the address and port in textual form. + */ + private String getAddressAndPort() { + return this.address.getHostAddress() + ":" + port; + } + + /** + * @return the login nickname. + */ + public SanitizedName getLoginNickname() { + return this.loginNickname; + } + + /** + * Set the login nickname. + * + * @param loginNickname the login nickname. + */ + public void setLoginNickname(SanitizedName loginNickname) { + assert loginNickname != null; + + this.loginNickname = loginNickname; + } + + /** + * Attempt to connect to the server. + */ + public void tryConnect() { + if (this.hasConnection()) { + Messenger.error("Cannot connect while already connected."); + return; + } + + Messenger.info("Connecting to " + this.getAddressAndPort()); + + try { + this.connection = new Connection("conn", this.address, this.port, this::consumeCommand); + } catch (IOException | SecurityException | NullPointerException e) { + Messenger.error(e, "Cannot connect to server - aborting connect()"); + return; + } catch (IllegalArgumentException e) { + Messenger.error(e, "Port is outside the valid range of values - aborting connect()"); + return; + } + + this.startHeartbeat(); + + /* + * Introduce ourselves to the server. + */ + try { + this.sendCommand(new IntroduceCommand(this.loginNickname.toString())); + } catch (MalformedException e) { + assert false : "loginNickname contained malformed name"; + } + } + + /** + * @return whether the client has a connection. + */ + public boolean hasConnection() { + return this.connection != null; + } + + /** + * @return whether this client has its own actor (i.e., is logged in). + */ + public boolean hasOwnActor() { + return this.hasConnection() && !this.getOwnIdentifier().isNone() + && this.findActorByIdentifier(this.getOwnIdentifier()).isPresent(); + } + + /** + * @return the client's connection. + */ + private Connection getConnection() { + assert this.hasConnection(); + + return this.connection; + } + + /** + * Send a command to this client's underlying {@link #connection}. + * + * @param command the command to send. + */ + private void sendCommand(Command command) { + assert command != null : "cannot send null command"; + assert this.hasConnection() : "cannot send command while disconnected"; + + this.connection.sendCommandIfAlive(command); + } + + /** + * Disconnect from the server. + */ + public void tryDisconnect() { + if (!this.hasConnection()) { + Messenger.error("Cannot disconnect while not connected."); + return; + } + + this.sendCommand(new DisappearCommand()); + + this.heartbeat.stop(); + this.heartbeat = null; + + this.connection.destroy(); + this.connection = null; + + this.identifier = Identifier.NONE; + + this.forgetState(); + + Messenger.info("Disconnected"); + + ClientGUI.changeRequestedView(Views.START); + + } + + /** + * Start the heartbeat schedule. + */ + private void startHeartbeat() { + assert this.heartbeat == null : "heartbeat not null"; + + this.heartbeat = Schedule.withFixedDelay(() -> { + if (this.hasConnection()) { + Connection connection = this.getConnection(); + + if (connection.isAwaitingPong()) { + Messenger.error("Connection timed out - disconnecting"); + this.tryDisconnect(); + } else { + connection.ping(); + } + } + return null; + }, Main.PONG_WAIT_MS, "heartbeat"); + } + + /** + * @return this client's {@link #identifier}. + */ + public Identifier getOwnIdentifier() { + return this.identifier; + } + + /** + * Set this client's {@link #identifier}. + * + * @param identifier the {@link Identifier}. + */ + private void setOwnIdentifier(Identifier identifier) { + assert this.getOwnIdentifier().isNone() : "already has identifier"; + assert identifier != null : "identifier is null"; + assert !identifier.isNone() : "identifier is none"; + + this.identifier = identifier; + + Messenger.info("Set own identifier to " + this.getOwnIdentifier()); + } + + /** + * @return all known actors. + */ + public Actor[] getActors() { + return this.actors.toArray(new Actor[0]); + } + + /** + * @return the highscores. + */ + public Highscores getHighscores() { + return this.highscores; + } + + /** + * @return whether the debug mode is active. + */ + public boolean isDebugMode() { + return this.debugMode; + } + + /** + * Set whether the debug mode is active. + * + * @param debugMode whether the debug mode is active. + */ + public void setDebugMode(final boolean debugMode) { + this.debugMode = debugMode; + } + + /** + * Add an actor. + * + * @param identifier the actor's identifier. + * @param nickname the actor's nickname. + */ + private void addActor(Identifier identifier, final String nickname) { + assert !identifier.isNone() : "invalid identifier"; + assert nickname != null : "nickname is null"; + assert this.findActorByIdentifier(identifier).isEmpty() : "actor already known"; + + Actor actor = new Actor(identifier); + actor.setRecord(new Record(nickname, null)); + + synchronized (this.actors) { + this.actors.add(actor); + } + + Messenger.info("'" + nickname + "' exists with identifier " + identifier); + } + + /** + * Get an actor via its identifier. + * + * @param identifier the actor's identifier. + * @return an {@link Optional} that may or may not contain the actor, depending on if it is + * found. + */ + private Optional findActorByIdentifier(Identifier identifier) { + if (identifier.isNone()) { + return Optional.empty(); + } + + synchronized (this.actors) { + for (var actor : this.actors) { + if (actor.getIdentifier().equals(identifier)) { + return Optional.of(actor); + } + } + } + + return Optional.empty(); + } + + /** + * Get an actor via its nickname. + * + * @param nickname the actor's nickname. + * @return an {@link Optional} that may or may not contain the actor, depending on if it is + * found. + */ + private Optional findActorByNickname(String nickname) { + assert nickname != null : "nickname is null"; + + synchronized (this.actors) { + for (var actor : this.actors) { + if (actor.record().orElseThrow().getNickname().equals(nickname)) { + return Optional.of(actor); + } + } + } + + return Optional.empty(); + } + + /** + * Remove an actor. + * + * @param actor the actor. + */ + private void removeActor(Actor actor) { + assert actor != null : "actor is null"; + + synchronized (this.actors) { + assert this.actors.contains(actor) : "actor not known"; + + this.actors.remove(actor); + } + + Messenger.info("'" + actor.record().orElseThrow().getNickname() + "' logged out"); + } + + /** + * Get our own actor. + * + * @return our actor. + */ + public Actor getOwnActor() { + assert hasOwnActor() : "have no own actor"; + + var actorOrEmpty = this.findActorByIdentifier(this.getOwnIdentifier()); + assert actorOrEmpty.isPresent(); + + return actorOrEmpty.orElseThrow(); + } + + /** + * All known lobbies. + */ + public synchronized Lobby[] getLobbies() { + return this.lobbies.toArray(new Lobby[0]); + } + + private synchronized void addLobby(String name) { + assert name != null : "name is null"; + assert this.findLobby(name).isEmpty() : "lobby already exists"; + + Lobby lobby; + + lobby = new Lobby(false, name); + this.lobbies.add(lobby); + + Messenger.info("Lobby '" + lobby.getName() + "' is open"); + } + + private synchronized Optional findLobby(String name) { + assert name != null : "name is null"; + + for (var lobby : this.lobbies) { + if (lobby.getName().equals(name)) { + return Optional.of(lobby); + } + } + + return Optional.empty(); + } + + private synchronized void addActorToLobby(Actor actor, Lobby lobby) { + assert actor != null : "actor is null"; + assert lobby != null : "lobby is null"; + + actor.setLobby(lobby); + lobby.addMember(actor); + + Messenger.info( + "'" + actor.record().orElseThrow().getNickname() + "' is in lobby '" + lobby.getName() + + "'"); + } + + private synchronized void removeActorFromLobby(Actor actor) { + assert actor != null : "actor is null"; + assert actor.member().isPresent() : "not member of a lobby"; + assert actor.member().orElseThrow().getLobby() + .contains(actor) : "lobby does not contain actor"; + + Lobby lobby = actor.member().orElseThrow().getLobby(); + + if (actor.getIdentifier().equals(this.getOwnIdentifier())) { + lobby.stopGame(true); + } + lobby.removeMember(actor); + actor.clearLobby(); + } + + private synchronized void removeLobby(Lobby lobby) { + assert lobby != null : "lobby is null"; + + assert this.lobbies.contains(lobby) : "unknown lobby"; + assert lobby.isEmpty(); + + this.lobbies.remove(lobby); + } + + /** + * Consume a command. This method is supplied to + * {@link Connection#Connection(String, InetAddress, int, Consumer)} during + * {@link #tryConnect()}. + * + * @param command the command to process. + */ + private void consumeCommand(Command command) { + /* + * These commands can always be consumed. + */ + + Identifier identifier = command.getSubject(); + var actorOrEmpty = this.findActorByIdentifier(identifier); + + if (command instanceof HighscoresCommand highscoresCommand) { + this.highscores = highscoresCommand.getHighscores(); + return; + } + + if (command instanceof IntroduceCommand introduceCommand) { + if (actorOrEmpty.isEmpty()) { + this.addActor(identifier, introduceCommand.getNickname()); + return; + } + + String nickname = introduceCommand.getNickname(); + var actor = actorOrEmpty.orElseThrow(); + Messenger.user( + actor.record().orElseThrow().getNickname() + " changed their name to " + nickname + + "."); + actor.record().orElseThrow().setNickname(nickname); + return; + } + + if (command instanceof DisappearCommand && identifier.equals(Identifier.NONE)) { + this.tryDisconnect(); + return; + } + + if (command instanceof RefreshCommand) { + // TODO: Only do this if this is the first time we're refreshing. + ClientGUI.changeRequestedView(Views.LOBBIES); + this.forgetState(); + this.setOwnIdentifier(identifier); + return; + } + + if (command instanceof LobbyOpenCommand lobbyOpenCommand) { + this.addLobby(lobbyOpenCommand.getLobbyName()); + return; + } + + if (command instanceof GameTimeCommand gameTimeCommand) { + var lobbyOrEmpty = this.findLobby(gameTimeCommand.getLobbyName()); + if (lobbyOrEmpty.isEmpty()) { + /* This is expected: the server informs us that a game has ended because a lobby was closed, but we've already closed it locally. */ + assert gameTimeCommand.getTicksUntilFinished() == 0; + return; + } + + Lobby lobby = lobbyOrEmpty.orElseThrow(); + boolean isOwnLobby = this.hasOwnActor() && this.getOwnActor().member().isPresent() + && gameTimeCommand.getLobbyName() + .equals(this.getOwnActor().member().orElseThrow().getLobby().getName()); + + /* Stop an ongoing game. */ + if (gameTimeCommand.getTicksUntilFinished() <= 0) { + if (isOwnLobby) { + ClientGUI.changeRequestedView(Views.LOBBY); + Messenger.user("Game over!"); + } + lobby.stopGame(false); + return; + } + + /* Update an ongoing game. */ + if (lobby.gameIsRunning() && lobby.game().isPresent()) { + lobby.game().orElseThrow().ticksUntilGameOver.set( + gameTimeCommand.getTicksUntilFinished()); + return; + } + + /* Start a new game. */ + if (isOwnLobby) { + lobby.startGame(gameTimeCommand.getTicksUntilFinished()); + ClientGUI.changeRequestedView(Views.GAME); + Messenger.user("Game started!"); + } else { + lobby.setGameIsRunning(true); + } + return; + } + + if (command instanceof LobbyCloseCommand lobbyCloseCommand) { + var lobbyOrEmpty = this.findLobby(lobbyCloseCommand.getLobbyName()); + assert lobbyOrEmpty.isPresent() : "received close for unknown lobby"; + Lobby lobby = lobbyOrEmpty.orElseThrow(); + assert lobby.isEmpty(); + + Messenger.info("'" + lobby.getName() + "' closed."); + this.removeLobby(lobby); + return; + } + + if (command instanceof GameUpdateWorkbenchCommand gameUpdateWorkbenchCommand) { + if (!this.hasOwnActor() || this.getOwnActor().member().isEmpty() || !this.getOwnActor() + .member().orElseThrow().getLobby().gameIsRunning()) { + Messenger.warn("Received WorkbenchCommand command while game is not running"); + return; + } + + Workbench affectedWorkbench = this.getOwnActor().member().orElseThrow().getLobby() + .game().orElseThrow().consumeWorkbenchCommand(gameUpdateWorkbenchCommand); + + if (affectedWorkbench instanceof CustomerWorkbench customerWorkbench + && customerWorkbench.getState() == State.EXPIRED) { + // Sounds.ANGRY.play(); + } else if (affectedWorkbench instanceof TransformerWorkbench) { + if (affectedWorkbench.getState() == State.EXPIRED) { + Sounds.EXPIRE.play(); + } else if (!(affectedWorkbench instanceof ChoppingWorkbench)) { + // Sounds.UPDATE.play(); + } + } + + Messenger.debug( + "Workbench " + affectedWorkbench.getIdentifier() + " is now " + + affectedWorkbench.getState() + + " with contents: " + affectedWorkbench.peekContents()); + return; + } + + if (command instanceof GameScoreCommand gameScoreCommand) { + if (!this.hasOwnActor() || this.getOwnActor().member().isEmpty() || !this.getOwnActor() + .member().orElseThrow().getLobby().gameIsRunning()) { + Messenger.warn("Received ScoreCommand while game is not running"); + return; + } + + this.getOwnActor().member().orElseThrow().getLobby().game().orElseThrow() + .forceSetScore(gameScoreCommand.getScore()); + + return; + } + + /* + * These commands cannot be anonymous and require us to know our own identifier. + */ + + if (this.identifier.isNone()) { + Messenger.warn("Ignoring incoming command before log-in"); + return; + } + assert !identifier.isNone() : "server is sending forbidden anonymous command"; + + /* + * These commands require the actor to be known. + */ + + assert actorOrEmpty.isPresent() : "unknown actor"; + + if (command instanceof DisappearCommand) { + this.removeActor(actorOrEmpty.orElseThrow()); + if (identifier.equals(this.getOwnIdentifier())) { + this.tryDisconnect(); + } + return; + } + + if (command instanceof LobbyJoinCommand lobbyJoinCommand) { + var lobbyOrEmpty = this.findLobby(lobbyJoinCommand.getLobbyName()); + assert lobbyOrEmpty.isPresent() : "received join to unknown lobby"; + this.addActorToLobby(actorOrEmpty.orElseThrow(), lobbyOrEmpty.orElseThrow()); + if (actorOrEmpty.orElseThrow().getIdentifier().equals(this.getOwnIdentifier())) { + ClientGUI.changeRequestedView(Views.LOBBY); + } + return; + } + + if (command instanceof LobbyLeaveCommand) { + Messenger.info( + "'" + actorOrEmpty.orElseThrow().record().orElseThrow().getNickname() + + "' left lobby '" + actorOrEmpty.orElseThrow().member().orElseThrow() + .getLobby().getName() + + "'"); + this.removeActorFromLobby(actorOrEmpty.orElseThrow()); + if (this.hasOwnActor() && actorOrEmpty.orElseThrow().getIdentifier() + .equals(this.getOwnIdentifier())) { + ClientGUI.changeRequestedView(Views.LOBBIES); + } + return; + } + + if (command instanceof ChatCommand chatCommand) { + assert !chatCommand.isWhispered() + || identifier.equals(this.getOwnIdentifier()) + || chatCommand.getRecipient().equals(this.getOwnIdentifier()) + : "received leaked private message"; + if (chatCommand.isWhispered()) { + var recipientOrEmpty = this.findActorByIdentifier( + chatCommand.getRecipient()); + if (recipientOrEmpty.isEmpty()) { + Messenger.error("Received whisper from unknown actor"); + } else { + Messenger.chat(actorOrEmpty.orElseThrow().record().orElseThrow().getNickname(), + recipientOrEmpty.orElseThrow().record().orElseThrow().getNickname(), + chatCommand.getMessage()); + } + } else { + Messenger.chat(actorOrEmpty.orElseThrow().record().orElseThrow().getNickname(), + chatCommand.getMessage()); + } + return; + } + + if (command instanceof YellCommand yellCommand) { + Messenger.yell(actorOrEmpty.orElseThrow().record().orElseThrow().getNickname(), + yellCommand.getMessage()); + return; + } + + if (command instanceof LobbyReadyCommand lobbyReadyCommand) { + actorOrEmpty.orElseThrow().member().orElseThrow() + .setReady(lobbyReadyCommand.getReady()); + return; + } + + if (command instanceof PlayerPositionCommand playerPositionCommand) { + if (!this.hasOwnActor() || this.getOwnActor().member().isEmpty() || !this.getOwnActor() + .member().orElseThrow().getLobby().gameIsRunning()) { + Messenger.warn("Received PositionCommand command while own game is not running"); + return; + } + + /* Create a player if one doesn't already exist. */ + Member member = actorOrEmpty.orElseThrow().member().orElseThrow(); + Player player; + if (member.player().isEmpty()) { + member.setPlayer(new Player(playerPositionCommand.getCoords())); + player = member.player().orElseThrow(); + } else { + player = actorOrEmpty.orElseThrow().member().orElseThrow().player() + .orElseThrow(); + } + + player.getPreviousCoords().setX(player.getRealCoords().getX()); + player.getPreviousCoords().setY(player.getRealCoords().getY()); + if (actorOrEmpty.orElseThrow().getIdentifier() + .equals(Client.the().getOwnIdentifier())) { + player.getRect().setX(player.getRealCoords().getX()); + player.getRect().setY(player.getRealCoords().getY()); + } + player.getRealCoords().setX(playerPositionCommand.getCoords().getX()); + player.getRealCoords().setY(playerPositionCommand.getCoords().getY()); + return; + } + + if (command instanceof PlayerHoldingCommand playerHoldingCommand) { + if (!this.hasOwnActor() || this.getOwnActor().member().isEmpty() || !this.getOwnActor() + .member().orElseThrow().getLobby().gameIsRunning()) { + Messenger.warn("Received HoldingCommand command while game is not running"); + return; + } + + Player player = actorOrEmpty.orElseThrow().member().orElseThrow().player() + .orElseThrow(); + + this.getOwnActor().member().orElseThrow().getLobby().game().orElseThrow() + .consumeHoldingCommand(player, playerHoldingCommand); + + Messenger.debug( + "'" + actorOrEmpty.orElseThrow().record().orElseThrow().getNickname() + + "' is now holding: " + + player.getHolding()); + return; + } + + /* + * These commands require the game to be running. + */ + + assert this.getOwnActor().member().orElseThrow().getLobby().game() + .isPresent() : "game not running"; + + Messenger.error("Ignoring unsupported command: " + command); + } + + public void sendReady() { + + this.sendCommand(new LobbyReadyCommand(true)); + } + + /** + * Suggest a new nickname for ourselves. The server cannot guarantee to accept our suggestion + * exactly as we provide it: see {@link Record#setNickname(String)}. + * + * @param nickname the suggestion. + */ + public void tryNickname(String nickname) { + if (this.hasConnection()) { + try { + this.sendCommand(new IntroduceCommand(nickname)); + } catch (MalformedException e) { + Messenger.error("Malformed nickname."); + } + } else { + Messenger.error("Cannot set nickname while disconnected."); + } + } + + /** + * Open a new lobby. The server cannot guarantee to accept our suggested lobby name exactly as + * we provide it: see + * {@link ch.unibas.dmi.dbis.cs108.letuscook.server.Server#createLobbyThenAddActor(String, + * Actor)}. + * + * @param suggestedName the suggested lobby name. + */ + public void tryOpenLobby(String suggestedName) { + if (this.hasConnection()) { + try { + this.sendCommand(new LobbyOpenCommand(suggestedName)); + } catch (MalformedException e) { + Messenger.error("Malformed lobby name."); + } + } else { + Messenger.error("Cannot open lobby while disconnected."); + } + } + + /** + * Attempt to join a lobby. + * + * @param name the lobby name. + */ + public void tryJoinLobby(String name) { + if (this.hasConnection()) { + try { + this.sendCommand(new LobbyJoinCommand(name)); + } catch (MalformedException e) { + Messenger.error("Malformed lobby name."); + } + } else { + Messenger.error("Cannot join lobby while disconnected."); + } + } + + public void tryParticipate() { + if (this.getOwnActor().member().orElseThrow().getLobby().gameIsRunning()) { + this.sendCommand(new GameParticipateCommand()); + } else { + Messenger.error("No participation request sent because game is not running"); + } + } + + public void tryMove(Coords coords) { + if (this.getOwnActor().member().orElseThrow().player().isPresent()) { + this.sendCommand(new PlayerPositionCommand(coords)); + } else { + Messenger.error("No coordinates sent cause game is not running"); + } + } + + public void tryInteract() { + if (this.getOwnActor().member().orElseThrow().player().isPresent()) { + this.tryMove( + this.getOwnActor().member().orElseThrow().player().orElseThrow().getRect()); + this.sendCommand(new PlayerInteractCommand()); + } else { + Messenger.error("Not interacting cause game is not running"); + } + } + + /** + * Leave the lobby. + */ + public void tryLeaveLobby() { + if (this.getOwnActor().member().isEmpty()) { + Messenger.error("Cannot leave lobby - not a member of a lobby"); + return; + } + + this.sendCommand(new LobbyLeaveCommand()); + } + + public void tryChat(String message) { + if (!this.hasConnection()) { + Messenger.error("Cannot chat while disconnected"); + return; + } + + try { + this.sendCommand(new ChatCommand(message)); + } catch (MalformedException e) { + Messenger.error("Malformed chat message"); + } + } + + public void tryYell(String message) { + if (!this.hasConnection()) { + Messenger.error("Cannot yell while disconnected"); + return; + } + + try { + this.sendCommand(new YellCommand(message)); + } catch (MalformedException e) { + Messenger.error("Malformed yell message."); + } + } + + public void tryStartGame() { + if (this.getOwnActor().member().isEmpty()) { + Messenger.error("Cannot start game outside lobby"); + return; + } + if (this.getOwnActor().member().orElseThrow().getLobby().gameIsRunning()) { + Messenger.error("Game is already running."); + return; + } + + Messenger.info("Requesting to start game"); + + this.sendCommand(new GameRequestStartCommand()); + } + + public void tryWhisperViaNickname(String recipient, String message) { + assert recipient != null : "recipient is null"; + assert message != null : "message is null"; + + if (!this.hasConnection()) { + Messenger.error("Cannot whisper while disconnected"); + return; + } + + var actorOrEmpty = this.findActorByNickname(recipient); + if (actorOrEmpty.isEmpty()) { + Messenger.user("User does not exist."); + return; + } + + try { + this.sendCommand(new ChatCommand(actorOrEmpty.orElseThrow().getIdentifier(), message)); + } catch (MalformedException e) { + Messenger.error("Malformed whisper message"); + } + } + + private synchronized void forgetState() { + for (var actor : this.actors) { + if (actor.member().isPresent()) { + this.removeActorFromLobby(actor); + } + } + + for (var lobby : this.getLobbies()) { + this.removeLobby(lobby); + } + + this.lobbies.clear(); + this.actors.clear(); + + this.identifier = Identifier.NONE; + + View.clearChat(); + } + + public void tryRefresh() { + this.sendCommand(new RefreshCommand()); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/ChatCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/ChatCommand.java new file mode 100644 index 0000000..659c12b --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/ChatCommand.java @@ -0,0 +1,104 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedLine; + +/** + * Sent by the client to submit a chat or whisper message. Sent by the server to deliver chat or + * whisper messages. + */ +public class ChatCommand extends Command { + + public static final String KEYWORD = "SAY"; + + private final Identifier recipient; + + private final SanitizedLine message; + + /** + * Constructs a new chat command with the specified message. + * + * @param message The message to be sent. + */ + public ChatCommand(String message) throws MalformedException { + this.recipient = Identifier.NONE; + this.message = SanitizedLine.createOrThrow(message); + } + + /** + * Constructs a new whisper command with the specified message. + * + * @param recipient the recipient. + * @param message The message to be sent. + */ + public ChatCommand(Identifier recipient, String message) throws MalformedException { + if (recipient.isNone()) { + throw new MalformedException("no recipient"); + } + + this.recipient = recipient; + this.message = SanitizedLine.createOrThrow(message); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static ChatCommand fromArguments(String arguments) throws MalformedException { + if (arguments == null) { + throw new MalformedException("arguments is null"); + } + + var recipientAndMessage = arguments.split(" ", 2); + + if (recipientAndMessage.length != 2) { + throw new MalformedException("malformed arguments"); + } + + var recipient = Identifier.fromString(recipientAndMessage[0]); + + if (recipient.isNone()) { + return new ChatCommand(recipientAndMessage[1]); + } + + return new ChatCommand(recipient, recipientAndMessage[1]); + } + + /** + * Gets the chat message associated with this command. + * + * @return The chat message. + */ + public String getMessage() { + return this.message.toString(); + } + + /** + * @return the recipient. + */ + public Identifier getRecipient() { + assert this.isWhispered() : "cannot get recipient of public chat command"; + + return this.recipient; + } + + /** + * @return whether this message is whispered. + */ + public boolean isWhispered() { + return !this.recipient.isNone(); + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + ChatCommand.KEYWORD + " " + this.recipient + " " + + this.getMessage(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/Command.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/Command.java new file mode 100644 index 0000000..3eabcd4 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/Command.java @@ -0,0 +1,101 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedLine; + +/** + * This abstract class is responsible for communication between client and server using commands. + * Each command is responsible for a significant execution. + */ +public abstract class Command { + + /** + * Whom this command concerns. + */ + private Identifier subject = Identifier.NONE; + + /** + * Parse a command. + * + * @param string the string containing the textual representation of the command. + * @return the resulting command. + * @throws MalformedException if the command is malformed. + */ + public static Command fromString(String string) throws MalformedException { + if (!SanitizedLine.qualifies(string)) { + throw new MalformedException("not a safe string"); + } + + String[] ika = string.split(" ", 3); /* identifier, keyword, and arguments */ + if (ika.length < 2) { + throw new MalformedException("missing keyword"); + } + String arguments = ika.length == 3 ? ika[2] : null; + + Command command = switch (ika[1]) { + case PingCommand.KEYWORD -> PingCommand.fromArguments(arguments); + case PongCommand.KEYWORD -> PongCommand.fromArguments(arguments); + case DisappearCommand.KEYWORD -> DisappearCommand.fromArguments(arguments); + case RefreshCommand.KEYWORD -> RefreshCommand.fromArguments(arguments); + case IntroduceCommand.KEYWORD -> IntroduceCommand.fromArguments(arguments); + case LobbyOpenCommand.KEYWORD -> LobbyOpenCommand.fromArguments(arguments); + case LobbyCloseCommand.KEYWORD -> LobbyCloseCommand.fromArguments(arguments); + case LobbyJoinCommand.KEYWORD -> LobbyJoinCommand.fromArguments(arguments); + case LobbyLeaveCommand.KEYWORD -> LobbyLeaveCommand.fromArguments(arguments); + case LobbyReadyCommand.KEYWORD -> LobbyReadyCommand.fromArguments(arguments); + case GameRequestStartCommand.KEYWORD -> + GameRequestStartCommand.fromArguments(arguments); + case GameForceStopCommand.KEYWORD -> GameForceStopCommand.fromArguments(arguments); + case GameTimeCommand.KEYWORD -> GameTimeCommand.fromArguments(arguments); + case PlayerPositionCommand.KEYWORD -> PlayerPositionCommand.fromArguments(arguments); + case ChatCommand.KEYWORD -> ChatCommand.fromArguments(arguments); + case YellCommand.KEYWORD -> YellCommand.fromArguments(arguments); + case GameUpdateWorkbenchCommand.KEYWORD -> + GameUpdateWorkbenchCommand.fromArguments(arguments); + case GameOrderCommand.KEYWORD -> GameOrderCommand.fromArguments(arguments); + case PlayerInteractCommand.KEYWORD -> PlayerInteractCommand.fromArguments(arguments); + case PlayerHoldingCommand.KEYWORD -> PlayerHoldingCommand.fromArguments(arguments); + case GameScoreCommand.KEYWORD -> GameScoreCommand.fromArguments(arguments); + case HighscoresCommand.KEYWORD -> HighscoresCommand.fromArguments(arguments); + case GameParticipateCommand.KEYWORD -> GameParticipateCommand.fromArguments(arguments); + default -> throw new MalformedException("unknown keyword"); + }; + + command.setSubject(Identifier.fromString(ika[0])); + + return command; + } + + public static Command withSubject(Identifier subject, Command command) { + assert subject.isSome(); + + command.setSubject(subject); + + return command; + } + + /** + * @return whom this command concerns. + */ + public Identifier getSubject() { + return this.subject; + } + + /** + * Sets the identifier associated with this command. + * + * @param subject The identifier to be set. + */ + public void setSubject(Identifier subject) { + this.subject = subject; + } + + /** + * @return a textual representation of the command. + */ + @Override + public String toString() { + return this.subject + " "; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/DisappearCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/DisappearCommand.java new file mode 100644 index 0000000..9a1e2a4 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/DisappearCommand.java @@ -0,0 +1,45 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the client to inform the server that it wants to disconnect. Sent by the server to + * indicate a client disconnecting (the identifier may be {@link Identifier#NONE} if this command is + * sent to a client without an attached record). + */ +public class DisappearCommand extends Command { + + public static final String KEYWORD = "BYE"; + + /** + * Constructs a DisconnectCommand. + */ + public DisappearCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static DisappearCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new DisappearCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command. + */ + @Override + public String toString() { + return super.toString() + DisappearCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameForceStopCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameForceStopCommand.java new file mode 100644 index 0000000..4e82139 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameForceStopCommand.java @@ -0,0 +1,45 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Represents a command sent by the client to stop a game. + */ +public class GameForceStopCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "STOP"; + + /** + * Constructs a new StopGameCommand. + */ + public GameForceStopCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameForceStopCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new GameForceStopCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command. + */ + @Override + public String toString() { + return super.toString() + GameForceStopCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameOrderCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameOrderCommand.java new file mode 100644 index 0000000..e5e4fce --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameOrderCommand.java @@ -0,0 +1,71 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Order; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +public class GameOrderCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "ORDER"; + + /** + * The workbench identifier. + */ + private final Identifier identifier; + + private final Order order; + + /** + * Constructs a new WorkbenchCommand. + */ + public GameOrderCommand(Identifier identifier, Order order) { + this.identifier = identifier; + this.order = order; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameOrderCommand fromArguments(String arguments) throws MalformedException { + if (arguments == null) { + throw new MalformedException("arguments is null"); + } + + var io /* identifier, order */ = arguments.split(" ", 2); + if (io.length != 2) { + throw new MalformedException("malformed arguments"); + } + + return new GameOrderCommand(Identifier.fromString(io[0]), Order.fromString(io[1])); + } + + /** + * @return the workbench identifier. + */ + public Identifier getWorkbenchIdentifier() { + return this.identifier; + } + + /** + * @return the order. + */ + public Order getOrder() { + return this.order; + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + GameOrderCommand.KEYWORD + " " + this.getWorkbenchIdentifier() + + " " + this.getOrder(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameParticipateCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameParticipateCommand.java new file mode 100644 index 0000000..ce8fadf --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameParticipateCommand.java @@ -0,0 +1,41 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the client to join the game. + */ +public class GameParticipateCommand extends Command { + + public static final String KEYWORD = "PARTICIPATE"; + + /** + * Constructs a GameParticipateCommand. + */ + public GameParticipateCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameParticipateCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new GameParticipateCommand(); + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + GameParticipateCommand.KEYWORD; + } + +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameRequestStartCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameRequestStartCommand.java new file mode 100644 index 0000000..04ab26e --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameRequestStartCommand.java @@ -0,0 +1,46 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Represents a command sent by the client to start a game. + */ +public class GameRequestStartCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "START"; + + /** + * Constructs a new StartGameCommand. + */ + public GameRequestStartCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameRequestStartCommand fromArguments(String arguments) + throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new GameRequestStartCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command. + */ + @Override + public String toString() { + return super.toString() + GameRequestStartCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameScoreCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameScoreCommand.java new file mode 100644 index 0000000..a212bc1 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameScoreCommand.java @@ -0,0 +1,58 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the server to update the score. + */ +public class GameScoreCommand extends Command { + + /** + * The keyword. + */ + public static final String KEYWORD = "SCORE"; + + /** + * The score. + */ + private final int score; + + /** + * Create a new instance of this command. + * + * @param score the score. + */ + public GameScoreCommand(final int score) { + this.score = score; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameScoreCommand fromArguments(String arguments) throws MalformedException { + if (arguments == null) { + throw new MalformedException("null arguments"); + } + + return new GameScoreCommand(Integer.parseInt(arguments)); + } + + /** + * @return the score. + */ + public int getScore() { + return this.score; + } + + /** + * @return a textual representation of the command. + */ + @Override + public String toString() { + return super.toString() + GameScoreCommand.KEYWORD + " " + this.getScore(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameTimeCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameTimeCommand.java new file mode 100644 index 0000000..b6c33de --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameTimeCommand.java @@ -0,0 +1,90 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; + +/** + * Sent by the server the inform the client how many ticks are left in a game. The client processes + * this information as follows: + *
    + *
  1. If there are 0 ticks remaining, the command is understood as a "Game Over"-notice.
  2. + *
  3. If the client did not previously know a game was running in the provided lobby, it starts the game.
  4. + *
  5. If the client already knew a game was running, it updates the ticks remaining.
  6. + *
+ */ +public class GameTimeCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "TIME"; + + /** + * The sanitized name of the lobby where the game started. + */ + private final SanitizedName lobbyName; + + /** + * How many ticks are left in this game. + */ + private final int ticksUntilFinished; + + /** + * Constructs a new GameStartedCommand with the specified lobby name. + * + * @param lobbyName The name of the lobby where the game started. + * @throws MalformedException If the lobby name is malformed. + */ + public GameTimeCommand(String lobbyName, int ticksUntilFinished) throws MalformedException { + this.lobbyName = SanitizedName.createOrThrow(lobbyName); + this.ticksUntilFinished = ticksUntilFinished; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameTimeCommand fromArguments(String arguments) throws MalformedException { + if (arguments == null) { + throw new MalformedException("arguments is null"); + } + + var lobbyAndTicks = arguments.split(" ", 2); + + if (lobbyAndTicks.length != 2) { + throw new MalformedException("malformed arguments"); + } + + return new GameTimeCommand(lobbyAndTicks[0], Integer.parseUnsignedInt(lobbyAndTicks[1])); + } + + /** + * Gets the name of the lobby where the game started. + * + * @return The name of the lobby. + */ + public String getLobbyName() { + return this.lobbyName.toString(); + } + + /** + * @return the ticks until the game is finished. + */ + public int getTicksUntilFinished() { + return this.ticksUntilFinished; + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command. + */ + @Override + public String toString() { + return super.toString() + GameTimeCommand.KEYWORD + " " + this.lobbyName + " " + + this.ticksUntilFinished; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameUpdateWorkbenchCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameUpdateWorkbenchCommand.java new file mode 100644 index 0000000..d41a8ef --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/GameUpdateWorkbenchCommand.java @@ -0,0 +1,111 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Stack; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.State; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Represents a command sent by the server to indicate the state of a workbench. Contains + * information about the workbench identifier, state, and contents. + */ + +public class GameUpdateWorkbenchCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "WORKBENCH"; + + /** + * The workbench identifier. + */ + private final Identifier identifier; + + /** + * The workbench state. + */ + private final State state; + + /** + * The workbench contents. + */ + private final Stack contents; + + /** + * Ticks until state change. + */ + private final int ticksUntilStateChange; + + /** + * Constructs a new WorkbenchCommand. + */ + public GameUpdateWorkbenchCommand(Identifier identifier, State state, Stack contents, + int ticksUntilStateChange) { + this.identifier = identifier; + this.state = state; + this.contents = contents; + this.ticksUntilStateChange = ticksUntilStateChange; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static GameUpdateWorkbenchCommand fromArguments(String arguments) + throws MalformedException { + if (arguments == null) { + throw new MalformedException("arguments is null"); + } + + var isct /* identifier, state, contents, ticks */ = arguments.split(" ", 4); + if (isct.length != 4) { + throw new MalformedException("malformed arguments"); + } + + return new GameUpdateWorkbenchCommand(Identifier.fromString(isct[0]), + State.fromString(isct[1]), Stack.fromString(isct[2]), Integer.parseInt(isct[3])); + } + + /** + * @return the workbench identifier. + */ + public Identifier getWorkbenchIdentifier() { + return this.identifier; + } + + /** + * @return the workbench state. + */ + public State getState() { + return this.state; + } + + /** + * @return the workbench contents. + */ + public Stack getContents() { + return this.contents; + } + + /** + * @return the ticks until finished. + */ + public int getTicksUntilStateChange() { + return this.ticksUntilStateChange; + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + GameUpdateWorkbenchCommand.KEYWORD + " " + + this.getWorkbenchIdentifier() + + " " + + this.getState() + " " + this.getContents() + " " + this.getTicksUntilStateChange(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/HighscoresCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/HighscoresCommand.java new file mode 100644 index 0000000..c3a8f9b --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/HighscoresCommand.java @@ -0,0 +1,35 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.server.Highscores; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +public class HighscoresCommand extends Command { + + public static final String KEYWORD = "SCORES"; + + private final Highscores highscores; + + public HighscoresCommand(Highscores highscores) { + this.highscores = highscores; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static HighscoresCommand fromArguments(String arguments) throws MalformedException { + return new HighscoresCommand(Highscores.fromString(arguments)); + } + + public Highscores getHighscores() { + return this.highscores; + } + + @Override + public String toString() { + return super.toString() + HighscoresCommand.KEYWORD + " " + this.getHighscores().toString(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/IntroduceCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/IntroduceCommand.java new file mode 100644 index 0000000..abbf218 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/IntroduceCommand.java @@ -0,0 +1,54 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; + +/** + * Sent by the client to set its own nickname. Sent by the server to announce any actor's nickname. + */ +public class IntroduceCommand extends Command { + + public static final String KEYWORD = "INTRO"; + + private final SanitizedName nickname; + + /** + * Constructs a IntroduceCommand with the specified nickname. + * + * @param nickname The nickname to be set. + */ + public IntroduceCommand(String nickname) throws MalformedException { + this.nickname = SanitizedName.createOrThrow(nickname); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static IntroduceCommand fromArguments(String arguments) throws MalformedException { + return new IntroduceCommand(arguments); + } + + /** + * Gets the nickname associated with this command. + * + * @return The nickname. + */ + public String getNickname() { + return this.nickname.toString(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command in the format: "[identifier] NICKNAME + * [nickname]". + */ + @Override + public String toString() { + return super.toString() + IntroduceCommand.KEYWORD + " " + this.nickname; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyCloseCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyCloseCommand.java new file mode 100644 index 0000000..7a9c732 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyCloseCommand.java @@ -0,0 +1,38 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; + +/** + * Sent by the server to announce the closing of a lobby. + */ +public class LobbyCloseCommand extends Command { + + public static final String KEYWORD = "CLOSE"; + + private final SanitizedName lobbyName; + + public LobbyCloseCommand(String lobbyName) throws MalformedException { + this.lobbyName = SanitizedName.createOrThrow(lobbyName); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static LobbyCloseCommand fromArguments(String arguments) throws MalformedException { + return new LobbyCloseCommand(arguments); + } + + public String getLobbyName() { + return this.lobbyName.toString(); + } + + @Override + public String toString() { + return super.toString() + LobbyCloseCommand.KEYWORD + " " + this.lobbyName; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyJoinCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyJoinCommand.java new file mode 100644 index 0000000..8f8d600 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyJoinCommand.java @@ -0,0 +1,60 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; + +/** + * Sent by the client to join a lobby. Sent by the server to indicate any client joining any lobby. + */ +public class LobbyJoinCommand extends Command { + + /** + * The keyword indicating the type of command. + */ + public static final String KEYWORD = "JOIN"; + /** + * The sanitized name of the lobby to join. + */ + private final SanitizedName lobbyName; + + /** + * Constructs a new JoinCommand object with the specified lobby name. + * + * @param lobbyName The name of the lobby to join. + * @throws MalformedException If the lobby name is malformed. + */ + public LobbyJoinCommand(String lobbyName) throws MalformedException { + this.lobbyName = SanitizedName.createOrThrow(lobbyName); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static LobbyJoinCommand fromArguments(String arguments) + throws MalformedException { + return new LobbyJoinCommand(arguments); + } + + /** + * Gets the sanitized name of the lobby to join. + * + * @return The sanitized lobby name. + */ + public String getLobbyName() { + return this.lobbyName.toString(); + } + + /** + * Returns a string representation of the JoinCommand. + * + * @return A string representation of the JoinCommand. + */ + @Override + public String toString() { + return super.toString() + LobbyJoinCommand.KEYWORD + " " + this.lobbyName; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyLeaveCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyLeaveCommand.java new file mode 100644 index 0000000..e892d2d --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyLeaveCommand.java @@ -0,0 +1,47 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the client to leave a lobby. Sent by the server to indicate any client leaving any + * lobby. + */ +public class LobbyLeaveCommand extends Command { + + /** + * The keyword indicating the type of command. + */ + public static final String KEYWORD = "LEAVE"; + + /** + * Constructs a new LeaveCommand object. + */ + public LobbyLeaveCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static LobbyLeaveCommand fromArguments(String arguments) + throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new LobbyLeaveCommand(); + } + + /** + * Returns a string representation of the LeaveCommand. + * + * @return A string representation of the LeaveCommand. + */ + @Override + public String toString() { + return super.toString() + LobbyLeaveCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyOpenCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyOpenCommand.java new file mode 100644 index 0000000..4f1773d --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyOpenCommand.java @@ -0,0 +1,40 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; + +/** + * Sent by the client to open and immediately join a lobby. If successful, the server responds with + * an {@link LobbyOpenCommand} followed by a {@link LobbyJoinCommand}. Clients other than the one + * that issued the request receive only the {@link LobbyOpenCommand}. + */ +public class LobbyOpenCommand extends Command { + + public static final String KEYWORD = "OPEN"; + + private final SanitizedName lobbyName; + + public LobbyOpenCommand(String lobbyName) throws MalformedException { + this.lobbyName = SanitizedName.createOrThrow(lobbyName); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static LobbyOpenCommand fromArguments(String arguments) throws MalformedException { + return new LobbyOpenCommand(arguments); + } + + public String getLobbyName() { + return this.lobbyName.toString(); + } + + @Override + public String toString() { + return super.toString() + LobbyOpenCommand.KEYWORD + " " + this.lobbyName; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyReadyCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyReadyCommand.java new file mode 100644 index 0000000..c12819f --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/LobbyReadyCommand.java @@ -0,0 +1,56 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the client to change its ready state. Sent by the server to indicate a change in ready + * state. + */ +public class LobbyReadyCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "READY"; + + /** + * The new value of the ready state. + */ + private final boolean ready; + + /** + * Constructs a new ReadyCommand with the specified ready state. + * + * @param ready the new ready state. + */ + public LobbyReadyCommand(boolean ready) { + this.ready = ready; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static LobbyReadyCommand fromArguments(String arguments) + throws MalformedException { + return new LobbyReadyCommand(Boolean.parseBoolean(arguments)); + } + + /** + * @return the ready state. + */ + public boolean getReady() { + return this.ready; + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + LobbyReadyCommand.KEYWORD + " " + this.getReady(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PingCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PingCommand.java new file mode 100644 index 0000000..3efa602 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PingCommand.java @@ -0,0 +1,43 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by either the client or the server to provoke a health check. + */ +public class PingCommand extends Command { + + public static final String KEYWORD = "PING"; + + /** + * Constructs a PingCommand. + */ + public PingCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static PingCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new PingCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command in the format: "[identifier] PING". + */ + @Override + public String toString() { + return super.toString() + PingCommand.KEYWORD; + } + +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerHoldingCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerHoldingCommand.java new file mode 100644 index 0000000..92880cd --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerHoldingCommand.java @@ -0,0 +1,52 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Stack; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Represents a command sent by the server to indicate the current holding stack. Contains + * information about the items in the stack. + */ +public class PlayerHoldingCommand extends Command { + + public static final String KEYWORD = "HAND"; + + private final Stack stack; + + /** + * Construct a new HoldingCommand. + * + * @param stack the stack. + */ + public PlayerHoldingCommand(Stack stack) { + assert stack != null; + + this.stack = stack; + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static PlayerHoldingCommand fromArguments(String arguments) throws MalformedException { + return new PlayerHoldingCommand(Stack.fromString(arguments)); + } + + /** + * @return the stack. + */ + public Stack getStack() { + return this.stack; + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + PlayerHoldingCommand.KEYWORD + " " + this.stack; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerInteractCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerInteractCommand.java new file mode 100644 index 0000000..4d7486e --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerInteractCommand.java @@ -0,0 +1,46 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Represents a command sent by the server to indicate an interaction. This command does not require + * any arguments. + */ +public class PlayerInteractCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "INTERACT"; + + /** + * Constructs a new InteractCommand. + */ + public PlayerInteractCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static PlayerInteractCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new PlayerInteractCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command. + */ + @Override + public String toString() { + return super.toString() + PlayerInteractCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerPositionCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerPositionCommand.java new file mode 100644 index 0000000..e6f204c --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PlayerPositionCommand.java @@ -0,0 +1,59 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.Coords; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the client to update its position. Sent by the server to update the player's positions. + */ +public class PlayerPositionCommand extends Command { + + /** + * The keyword. + */ + public static final String KEYWORD = "POSITION"; + + /** + * The coordinates. + */ + private final Coords coords; + + /** + * Create a new instance of this command. + * + * @param coordsOrRect the coordinates (or rect). + */ + public PlayerPositionCommand(Coords coordsOrRect) { + this.coords = new Coords(coordsOrRect.getX(), coordsOrRect.getY()); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static PlayerPositionCommand fromArguments(String arguments) throws MalformedException { + if (arguments == null) { + throw new MalformedException("null arguments"); + } + + return new PlayerPositionCommand(new Coords(arguments)); + } + + /** + * @return the coordinates. + */ + public Coords getCoords() { + return this.coords; + } + + /** + * @return a textual representation of the command. + */ + @Override + public String toString() { + return super.toString() + PlayerPositionCommand.KEYWORD + " " + this.getCoords(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PongCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PongCommand.java new file mode 100644 index 0000000..1e20454 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/PongCommand.java @@ -0,0 +1,42 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by either the client or the server in response to a {@link PingCommand}. + */ +public class PongCommand extends Command { + + public static final String KEYWORD = "PONG"; + + /** + * Constructs a PongCommand. + */ + public PongCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static PongCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new PongCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command in the format: "[identifier] PONG". + */ + @Override + public String toString() { + return super.toString() + PongCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/RefreshCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/RefreshCommand.java new file mode 100644 index 0000000..ee1a0e6 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/RefreshCommand.java @@ -0,0 +1,46 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; + +/** + * Sent by the client to request a refresh. Sent by the server to refresh the client, in which case + * the client reads this commands' identifier to determine its own identifier. + */ +public class RefreshCommand extends Command { + + /** + * The keyword used to identify this command. + */ + public static final String KEYWORD = "REFRESH"; + + /** + * Constructs a new RefreshCommand. + */ + public RefreshCommand() { + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static RefreshCommand fromArguments(String arguments) throws MalformedException { + if (arguments != null && !arguments.isEmpty()) { + throw new MalformedException("expected null or empty arguments"); + } + + return new RefreshCommand(); + } + + /** + * Returns a string representation of this command. + * + * @return A string representation of the command. + */ + @Override + public String toString() { + return super.toString() + RefreshCommand.KEYWORD; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/YellCommand.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/YellCommand.java new file mode 100644 index 0000000..f081fec --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/commands/YellCommand.java @@ -0,0 +1,49 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.commands; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedLine; + +/** + * Sent by the client to submit a chat message to all clients connected to the server. + */ +public class YellCommand extends Command { + + public static final String KEYWORD = "YELL"; + + private final SanitizedLine message; + + /** + * Constructs a new yell command with the specified message. + * + * @param message the message to be yelled. + */ + public YellCommand(String message) throws MalformedException { + this.message = SanitizedLine.createOrThrow(message); + } + + /** + * Creates an anonymous instance of this command, given a string of arguments. + * + * @param arguments the arguments + * @return the command. + * @throws MalformedException if the arguments are malformed. + */ + public static YellCommand fromArguments(String arguments) throws MalformedException { + return new YellCommand(arguments); + } + + /** + * @return the message. + */ + public String getMessage() { + return this.message.toString(); + } + + /** + * @return a textual representation of this command. + */ + @Override + public String toString() { + return super.toString() + YellCommand.KEYWORD + " " + this.getMessage(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/ClientGUI.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/ClientGUI.java new file mode 100644 index 0000000..1e44c00 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/ClientGUI.java @@ -0,0 +1,483 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Game; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Schedule; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import javafx.application.Application; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Rectangle2D; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonBar.ButtonData; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ChoiceBox; +import javafx.scene.control.Dialog; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.Slider; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextInputDialog; +import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; +import javafx.scene.media.MediaPlayer; +import javafx.stage.Screen; +import javafx.stage.Stage; + +/** + * Represents the graphical user interface for the Let Us Cook client application. + */ +public class ClientGUI extends Application { + + /** + * Frames per second. + */ + public static final int FPS = 30; + + private static final AtomicBoolean viewChanged = new AtomicBoolean(true); + + private static final AtomicBoolean popUpViewChanged = new AtomicBoolean(false); + + private static volatile ClientGUI the; + + private static volatile Views requestedView = Views.START; + + private static volatile Views requestedPopUpView = null; + + public double width = 0; + + public double height = 0; + + /** + * The current popup view. + */ + private View popUpView = null; + + /** + * The current frame. + */ + private int frame = 0; + + private MediaPlayer musicPlayer; + + private MediaPlayer angrySoundPlayer; + + private MediaPlayer buttonSoundPlayer; + + private MediaPlayer interactSoundPlayer; + + private MediaPlayer expireSoundPlayer; + + private MediaPlayer updateSoundPlayer; + + /** + * The current view. + */ + private volatile View activeView = null; + + /** + * The stage. + */ + private Stage stage; + + /** + * Create the client GUI. + */ + public ClientGUI() { + assert !ClientGUI.exists(); + } + + public static ClientGUI the() { + assert ClientGUI.exists() : "clientgui not initialized"; + + return ClientGUI.the; + } + + public static boolean exists() { + return ClientGUI.the != null; + } + + public static void changeRequestedView(Views requestedView) { + ClientGUI.requestedView = requestedView; + ClientGUI.viewChanged.set(true); + } + + public static void changeRequestedPopUpView(Views requestedPopUpView) { + ClientGUI.requestedPopUpView = requestedPopUpView; + ClientGUI.popUpViewChanged.set(true); + } + + public View getActiveView() { + return activeView; + } + + public View getPopUpView() { + return popUpView; + } + + private void setPopUpView(View popUpview) { + this.popUpView = popUpview; + } + + /** + * Used by other threads to modify to GUI. + * + * @param runnable the code to run. + */ + void runLater(Runnable runnable) { + if (ClientGUI.exists()) { + Platform.runLater(runnable); + } + } + + /** + * Start the GUI. + * + * @param stage the primary stage for this application, created by JavaFX. + */ + @Override + public void start(Stage stage) { + ClientGUI.the = this; + + double a = Screen.getPrimary().getDpi() / (Screen.getPrimary().getOutputScaleX() + * Screen.getPrimary().getBounds().getWidth()); + double b = 107d / (3 * 1280); + double c = 1.3 * 0.75 * a / b; + Fonts.SCALE = c; + + /* Music. */ + Sounds.BACKGROUND.play(); + + /* Prepare stage. */ + this.stage = stage; + this.stage.setOnCloseRequest(event -> System.exit(1)); + this.stage.getIcons().add(Images.get(Images.ICON)); + Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); + this.stage.setX(bounds.getMinX()); + this.stage.setY(bounds.getMinY()); + this.stage.setWidth(bounds.getWidth()); + this.stage.setHeight(bounds.getHeight()); + this.stage.setMaximized(true); + + /* Show stage and measure scene dimensions. */ + var splashView = new SplashView(this.stage); + this.setView(splashView); + splashView.select(); + this.stage.show(); + this.scaleUnits(); + + /* Start. */ + this.stage.widthProperty() + .addListener((observable, oldValue, newValue) -> this.scaleUnits()); + this.stage.heightProperty() + .addListener((observable, oldValue, newValue) -> this.scaleUnits()); + Schedule.atFixedRate(this::draw, 1000 / FPS, "draw"); + } + + /** + * Show a view. + * + * @param view the view. + */ + private void setView(View view) { + this.activeView = view; + } + + private void scaleUnits() { + this.width = this.getActiveView().getScene().getWidth(); + this.height = this.getActiveView().getScene().getHeight(); + + double maxGameWidth = + this.width - this.getActiveView().getSidebarWidth() - this.getActiveView() + .getChatWidth(); + if (maxGameWidth > this.height) { + Units.scale(this.height / Game.HEIGHT.u()); + } else { + Units.scale(maxGameWidth / Game.WIDTH.u()); + } + } + + /** + * Show a chat message. + * + * @param message the message. + */ + public void addMessage(String color, String author, String recipient, String message) { + if (activeView != null) { + this.runLater(() -> activeView.addMessage(color, author, recipient, message)); + } + } + + /** + * Update the current view. + */ + private Object draw() { + this.frame = (this.frame + 1) % FPS; + + boolean isZeroFrame = this.frame == 0; + + this.runLater(() -> { + boolean viewChanged = ClientGUI.viewChanged.getAndSet(false); + if (viewChanged) { + this.setView(switch (requestedView) { + case START -> new StartView(this.stage); + case LOBBIES -> new LobbiesView(this.stage); + case TUTORIAL -> new TutorialView(this.stage); + case LOBBY -> new LobbyView(this.stage); + case GAME -> new GameView(this.stage); + default -> null; + }); + } + + boolean popUpViewChanged = ClientGUI.popUpViewChanged.getAndSet(false); + if (popUpViewChanged) { + this.setPopUpView(requestedPopUpView == null ? null : switch (requestedPopUpView) { + case HIGHSCORES -> new HighscoresView(stage); + case RECIPES -> new RecipesView(stage); + default -> null; + }); + } + + if (popUpViewChanged && popUpView != null) { + this.popUpView.select(); + this.scaleUnits(); + } else if (popUpView == null && (viewChanged) || popUpViewChanged) { + this.activeView.select(); + this.scaleUnits(); + } + + if (this.popUpView != null && (popUpViewChanged || isZeroFrame)) { + this.popUpView.draw(); + } else if (viewChanged || isZeroFrame || this.activeView instanceof KitchenView) { + this.activeView.draw(); + } + }); + + return null; + } + + public void showAlert(AlertType type, String title, String message, boolean wait) { + Alert alert = new Alert(type); + + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.getDialogPane().lookup(".content.label") + .setStyle("-fx-font-family: 'Arial'; -fx-font-size: 14px; -fx-text-fill: #333333;"); + + this.showAlert(alert, wait); + } + + public void showAlert(Alert alert, boolean wait) { + assert Thread.currentThread().getName().equals("JavaFX Application Thread"); + + DialogPane alertPane = alert.getDialogPane(); + Stage alertStage = (Stage) alertPane.getScene().getWindow(); + alertStage.getIcons().add(Images.get(Images.ICON)); + if (alertPane.getButtonTypes().get(0) == ButtonType.OK) { + alertStage.close(); + } + + if (wait) { + alert.showAndWait(); + } else { + alert.show(); + } + } + + public void showNicknameDialog() { + TextInputDialog nicknameDialog = new TextInputDialog(); + + nicknameDialog.setTitle("Choose a new nickname"); + nicknameDialog.setHeaderText(null); + nicknameDialog.setContentText("Please enter your nickname:"); + + DialogPane dialogPane = nicknameDialog.getDialogPane(); + dialogPane.setGraphic(new ImageView(Images.get(Images.ICON))); + Stage dialogStage = (Stage) dialogPane.getScene().getWindow(); + dialogStage.getIcons().add(Images.get(Images.ICON)); + + nicknameDialog.showAndWait().ifPresent(result -> { + if (!result.isEmpty()) { + Client.the().tryNickname(result); + } else { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Invalid nickname"); + alert.setHeaderText(null); + alert.setContentText("Invalid nickname! Please enter a valid nickname!"); + + this.showAlert(alert, false); + } + }); + } + + public void showWhisperDialog() { + List nicknames = new ArrayList<>(); + for (var actor : Client.the().getActors()) { + nicknames.add(actor.record().orElseThrow().getNickname()); + } + + // ChoiceBox mit den Nicknamen + ChoiceBox nicknameChoiceBox = new ChoiceBox<>(); + nicknameChoiceBox.getItems().addAll(nicknames); + + // Dialog für den Whisper Chat + Dialog whisperDialog = new Dialog<>(); + whisperDialog.setTitle("Whisper Chat"); + whisperDialog.setHeaderText("Enter a Whisper Message"); + + // Icon oben links + DialogPane dialogPane = whisperDialog.getDialogPane(); + dialogPane.setGraphic(new ImageView(Images.get(Images.ICON))); + Stage dialogStage = (Stage) dialogPane.getScene().getWindow(); + dialogStage.getIcons().add(Images.get(Images.ICON)); + + // Eingabefeld für die Nachricht + TextArea whisperInputField = new TextArea(); + whisperInputField.setPromptText("Type your message here..."); + + // Layout für den Dialog + VBox dialogContent = new VBox(10); + dialogContent.setPadding(new Insets(10)); + dialogContent.getChildren() + .addAll(new Label("Select recipient"), nicknameChoiceBox, whisperInputField); + whisperDialog.getDialogPane().setContent(dialogContent); + + // Buttons für den Dialog + ButtonType sendButtonType = new ButtonType("Send", ButtonBar.ButtonData.OK_DONE); + whisperDialog.getDialogPane().getButtonTypes() + .addAll(sendButtonType, ButtonType.CANCEL); + + // Validiere die Eingabe + whisperDialog.setResultConverter(dialogButton -> { + if (dialogButton == sendButtonType) { + return nicknameChoiceBox.getValue(); + } + return null; + }); + + // überprüfen und command senden + Optional result = whisperDialog.showAndWait(); + result.ifPresent(recipient -> { + String message = whisperInputField.getText().trim(); + if (!message.isEmpty()) { + Client.the().tryWhisperViaNickname(recipient, message); + } + }); + } + + public void showYellDialog() { + // Dialog für den globalen Chat + Dialog globalDialog = new Dialog<>(); + globalDialog.setTitle("Global Chat"); + globalDialog.setHeaderText("Talk with other Kitchens!"); + + // Icon oben links + DialogPane globalPane = globalDialog.getDialogPane(); + globalPane.setGraphic(new ImageView(Images.get(Images.ICON))); + Stage globalStage = (Stage) globalPane.getScene().getWindow(); + globalStage.getIcons().add(Images.get(Images.ICON)); + + // Eingabefeld für die Nachricht + TextArea globalInputField = new TextArea(); + globalInputField.setPromptText("Type your message here..."); + + // Layout für den Dialog + VBox globalDialogContent = new VBox(10); + globalDialogContent.setPadding(new Insets(10)); + globalDialogContent.getChildren() + .addAll(new Label("Enter a Global Message"), globalInputField); + globalDialog.getDialogPane().setContent(globalDialogContent); + + // Buttons für den Dialog + ButtonType sendButtonType = new ButtonType("Send", ButtonData.OK_DONE); + globalDialog.getDialogPane().getButtonTypes().addAll(sendButtonType, ButtonType.CANCEL); + + // Überprüfen und command schicken + Optional resultOptional = globalDialog.showAndWait(); + if (resultOptional.isPresent()) { + String message = globalInputField.getText().trim(); + if (!message.isEmpty()) { + Sounds.CLICK.play(); + Client.the().tryYell(message); + } + } + } + + public void showSettingsDialog() { + Dialog settingsDialog = new Dialog<>(); + settingsDialog.setTitle("Settings"); + settingsDialog.setHeaderText("Set your Kitchen!"); + + // Icon oben links + DialogPane settingsPane = settingsDialog.getDialogPane(); + settingsPane.setGraphic(new ImageView(Images.get(Images.ICON))); + Stage settingsStage = (Stage) settingsPane.getScene().getWindow(); + settingsStage.getIcons().add(Images.get(Images.ICON)); + + Label volumeLabel = new Label("Volume"); + Slider volumeSlider = new Slider(0, 100, + Sounds.BACKGROUND.getMediaPlayer().getVolume() * 100); + volumeSlider.setShowTickMarks(true); + volumeSlider.setShowTickLabels(true); + volumeSlider.valueProperty().addListener((observable, oldValue, newValue) -> { + double volume = newValue.doubleValue() + / 100.0; // Slider-Value anpassen -> 0 to 1 + Sounds.BACKGROUND.getMediaPlayer().setVolume(volume); + }); + + CheckBox muteMusicCheckbox = new CheckBox("Mute Background Music"); + muteMusicCheckbox.setSelected(Sounds.BACKGROUND.getMediaPlayer().isMute()); + muteMusicCheckbox.setOnAction( + e -> Sounds.BACKGROUND.getMediaPlayer().setMute(muteMusicCheckbox.isSelected())); + + CheckBox muteButtonSoundsCheckbox = new CheckBox("Mute Sound Effects"); + muteButtonSoundsCheckbox.setSelected(Sounds.getMuteSoundEffects()); + muteButtonSoundsCheckbox.setOnAction( + e -> Sounds.setMuteSoundEffects(muteButtonSoundsCheckbox.isSelected())); + + GridPane grid = new GridPane(); + grid.add(volumeLabel, 0, 0); + grid.add(volumeSlider, 1, 0); + grid.add(muteMusicCheckbox, 0, 1, 2, + 1); + grid.add(muteButtonSoundsCheckbox, 0, 2, 2, 1); + + settingsPane.setContent(grid); + + settingsPane.getButtonTypes().addAll(ButtonType.CLOSE); + + settingsDialog.showAndWait(); + } + + public void showLobbyDialog() { + TextInputDialog dialog = new TextInputDialog(); + dialog.setTitle("New lobby"); + dialog.setHeaderText(null); + dialog.setContentText("Please enter a name for your lobby:"); + + DialogPane dialogPane = dialog.getDialogPane(); + Stage dialogStage = (Stage) dialogPane.getScene().getWindow(); + dialogPane.setGraphic(new ImageView(Images.get(Images.ICON))); + dialogStage.getIcons().add(Images.get(Images.ICON)); + + dialog.showAndWait().ifPresent(result -> { + if (!result.isEmpty()) { + if (Client.the().getOwnActor().member().isEmpty()) { + Sounds.CLICK.play(); + Client.the().tryOpenLobby(result); + } + } + }); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/Controls.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/Controls.java new file mode 100644 index 0000000..0b63d9a --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/Controls.java @@ -0,0 +1,218 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.util.function.Consumer; +import java.util.function.IntPredicate; +import javafx.event.Event; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; + +/** + * Represents a utility class for creating GUI controls in the Let Us Cook application. + */ +public class Controls { + + public static final Units primaryButtonWidth = new Units(1.6); + + public static final Units primaryButtonHeight = new Units(1); + + public static final Units secondaryButtonWidth = new Units(1.5); + + public static final Units secondaryButtonHeight = new Units(0.75); + + public static final double sidebarWidthToHeightRatio = 64d / 20; + + private static double sidebarWidth() { + return 0.075 * ClientGUI.the().width; + } + + /** + * Returns the height of the sidebar. + * + * @return The height of the sidebar. + */ + private static double sidebarHeight() { + return sidebarWidth() / sidebarWidthToHeightRatio; + } + + /** + * Creates a sidebar button. + * + * @param icon The icon image. + * @param text The text of the button. + * @param runnable The action to be performed when the button is clicked. + * @return The created button. + */ + public static Button sidebarButton(Images icon, String text, Runnable runnable) { + var image = new ImageView(Images.get(icon)); + image.setPreserveRatio(true); + int imageInset = 5; + image.setFitWidth(sidebarHeight() - 2 * imageInset); + + var label = new Label(text); + label.setTextFill(Color.BLACK); + label.setPrefWidth(sidebarWidth() - sidebarHeight()); + label.setFont(Fonts.get(Fonts.UI_SECONDARY)); + + var span = new HBox(imageInset + 5, image, label); + span.setPadding(new Insets(0, 0, 0, imageInset)); + span.setPrefWidth(sidebarWidth()); + span.setAlignment(Pos.CENTER_LEFT); + + var background = new ImageView(Images.get(Images.SIDEBAR_BUTTON)); + background.setPreserveRatio(true); + background.setFitWidth(sidebarWidth()); + + var stack = new StackPane(); + stack.getChildren().addAll(background, span); + + var button = new Button(null, stack); + button.setPrefWidth(sidebarWidth()); + button.setStyle("-fx-background-color: transparent;"); + + button.setOnAction(event -> { + Sounds.CLICK.play(); + runnable.run(); + }); + + return button; + } + + /** + * Creates a primary button. + * + * @param text The text of the button. + * @param runnable The action to be performed when the button is clicked. + * @return The created button. + */ + public static Button primaryButton(String text, Runnable runnable) { + return bigButton(Images.PRIMARY_BUTTON, -primaryButtonHeight.px() / 8, text, runnable); + } + + /** + * Creates a secondary button. + * + * @param text The text of the button. + * @param runnable The action to be performed when the button is clicked. + * @return The created button. + */ + public static Button secondaryButton(String text, Runnable runnable) { + return bigButton(Images.SECONDARY_BUTTON, 0, text, runnable); + } + + /** + * Creates a big button. + * + * @param backgroundImage The background image of the button. + * @param translateY The Y-axis translation of the button. + * @param text The text of the button. + * @param runnable The action to be performed when the button is clicked. + * @return The created button. + */ + public static Button bigButton(Images backgroundImage, double translateY, String text, + Runnable runnable) { + var image = Images.get(backgroundImage); + + var imageView = new ImageView(image); + imageView.setPreserveRatio(true); + imageView.setFitWidth(image.getWidth()); + + var label = new Label(text); + label.setTranslateY(-image.getHeight() / 25 + translateY); + label.setAlignment(Pos.CENTER); + label.setWrapText(true); + label.setPrefWidth(image.getWidth()); + label.setFont(Fonts.get(Fonts.UI_PRIMARY)); + label.setTextFill(Color.BLACK); + + var stack = new StackPane(); + stack.getChildren().addAll(imageView, label); + + var button = new Button(null, stack); + button.setPrefWidth(image.getWidth()); + button.setPrefHeight(image.getHeight()); + button.setStyle("-fx-background-color: transparent;"); + + if (runnable == null) { + button.setOpacity(0.5); + } else { + button.setOnAction(event -> { + Sounds.CLICK.play(); + runnable.run(); + }); + } + + return button; + } + + /** + * Creates a text area. + * + * @param width The width of the text area. + * @param height The height of the text area. + * @param prompt The prompt text of the text area. + * @param wrap Whether the text area should wrap text. + * @param onKeyPressed The action to be performed when a key is pressed. + * @param filter The filter predicate for allowed characters. + * @param limit The maximum character limit. + * @return The created text area. + */ + public static TextArea textArea(double width, double height, String prompt, boolean wrap, + Consumer onKeyPressed, IntPredicate filter, int limit) { + final String backgroundColor = "#524c83"; + final String promptColor = "#6d66a0"; + final String textColor = "#B5B8DE"; + final String backgroundColorFocused = "#dedded"; + final String promptColorFocused = "#8a81bd"; + final String textColorFocused = "#222034"; + String style = + "-fx-control-inner-background: " + backgroundColor + ";" + + "-fx-prompt-text-fill: " + promptColor + ";" + + "-fx-text-fill: " + textColor + ";" + + "-fx-focus-color: transparent;" + + "-fx-text-box-border: transparent;" + + "-fx-faint-focus-color: transparent;"; + String styleFocused = style + + "-fx-control-inner-background: " + backgroundColorFocused + ";" + + "-fx-prompt-text-fill: " + promptColorFocused + ";" + + "-fx-text-fill: " + textColorFocused + ";"; + + var textArea = new TextArea(); + textArea.setWrapText(wrap); + textArea.setPrefSize(width, height); + textArea.setMaxSize(width, height); + textArea.setMinSize(width, height); + textArea.setPromptText(prompt); + + textArea.setStyle(style); + textArea.focusedProperty().addListener( + (observable, oldValue, newValue) -> textArea.setStyle(newValue ? styleFocused : style)); + textArea.setFont(Fonts.get(Fonts.UI_PRIMARY)); + + textArea.textProperty().addListener((((observable, oldValue, newValue) -> { + if ((filter != null && !newValue.chars().allMatch(filter)) || (limit > 0 + && newValue.length() > limit)) { + textArea.setText(oldValue); + } + }))); + textArea.setOnKeyPressed(event -> { + if (onKeyPressed != null) { + onKeyPressed.accept(event.getCode()); + } + event.consume(); + }); + textArea.setOnKeyReleased(Event::consume); + + return textArea; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/GameView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/GameView.java new file mode 100644 index 0000000..6e704a2 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/GameView.java @@ -0,0 +1,39 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import javafx.stage.Stage; + +/** + * Represents the view for the game in the Let Us Cook application. + */ +public class GameView extends KitchenView { + + /** + * Creates a new instance of GameView. + * + * @param stage The primary stage. + */ + public GameView(Stage stage) { + super( + stage, + Views.GAME, + Images.LEAVE, + () -> Client.the().tryLeaveLobby(), + "Leave Game" + ); + + var superOnKeyReleased = this.scene.getOnKeyReleased(); + this.scene.setOnKeyReleased(event -> { + switch (event.getCode()) { + case E, SPACE -> { + Client.the().tryInteract(); + Sounds.INTERACT.play(); + } + } + + superOnKeyReleased.handle(event); + }); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/HighscoresView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/HighscoresView.java new file mode 100644 index 0000000..589bde6 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/HighscoresView.java @@ -0,0 +1,86 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; + +/** + * Represents the view for displaying highscores in the Let Us Cook application. + */ +public class HighscoresView extends View { + + /** + * Creates a new instance of HighscoresView. + * + * @param stage The primary stage. + */ + HighscoresView(Stage stage) { + super(stage, + Views.HIGHSCORES, + new BorderPane(), + 800d, 600d, + "Let Us Cook!", + Images.get(Images.BACKGROUND), + Images.RETURN, + () -> ClientGUI.changeRequestedPopUpView(null), + "Back" + ); + + var title = new Label("Highscores"); + title.setFont(Fonts.get(Fonts.UI_TITLE)); + title.setTextFill(Color.WHITE); + + var list = new VBox(25); + + var highscores = Client.the().getHighscores().getHighscores(); + + for (int i = 0; i < highscores.size(); ++i) { + if (highscores.get(i).getScore() == 0) { + continue; + } + + var place = new Label(String.valueOf(i + 1) + "."); + place.setFont(Fonts.get(Fonts.UI_TITLE)); + place.setTextFill(switch (i) { + case 0 -> Color.GOLD; + case 1 -> Color.SILVER; + case 2 -> Color.color(120d / 255, 70d / 255, 40d / 255); + default -> Color.WHITE; + }); + place.setPrefWidth(this.getCenterWidth() * 1 / 20); + + var score = new Label(String.valueOf(highscores.get(i).getScore())); + score.setFont(Fonts.get(Fonts.UI_SCORE)); + score.setTextFill(Color.WHITE); + score.setPrefWidth(this.getCenterWidth() * 1 / 15); + + var players = new Label(String.join(", ", highscores.get(i).getNames())); + players.setFont(Fonts.get(Fonts.UI_PRIMARY)); + players.setTextFill(Color.WHITE); + + var span = new HBox(25, place, score, players); + span.setAlignment(Pos.CENTER_LEFT); + + list.getChildren().add(span); + } + + list.setAlignment(Pos.CENTER); + + var box = new HBox(list); + var aligner = new HBox(box); + aligner.setAlignment(Pos.CENTER); + + var container = new VBox(100, title, aligner); + container.setAlignment(Pos.CENTER); + container.prefHeightProperty().bind(this.getScene().heightProperty()); + + this.pane.setCenter(container); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/KitchenView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/KitchenView.java new file mode 100644 index 0000000..193ba02 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/KitchenView.java @@ -0,0 +1,321 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Workbench; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Actor; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Game; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Player; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Player.Facing; +import ch.unibas.dmi.dbis.cs108.letuscook.util.CanvasPane; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Coords; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Vector; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javafx.geometry.Pos; +import javafx.geometry.VPos; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import javafx.stage.Stage; + +/** + * The `KitchenView` class represents the graphical user interface (GUI) for the main game screen in + * the Let Us Cook game. It handles the rendering of various game elements including the kitchen + * floor, workbenches, orders, players, and game timer. + */ +public abstract class KitchenView extends View { + + /** + * Key states. + */ + public final Map keys = new ConcurrentHashMap<>(); + + /** + * The graphics context. + */ + private final GraphicsContext gc; + + /** + * The smallest movement speed allowed when interpolating player movement (experimental). + */ + int interpolMinSpeed = 5; + + /** + * The game. + */ + private Game game; + + /** + * Constructs a new `KitchenView` object with the specified stage. + * + * @param stage The stage for the game view + */ + KitchenView(Stage stage, Views view, Images returnButtonIcon, Runnable returnButtonAction, + String returnButtonLabel) { + super( + stage, + view, + new BorderPane(), + 800d, 600d, + "Let Us Cook!", + Images.get(Images.BACKGROUND), + returnButtonIcon, + returnButtonAction, + returnButtonLabel + ); + + this.game = + Client.the().getOwnActor().member().isPresent() ? Client.the().getOwnActor().member() + .orElseThrow().getLobby().game() + .orElseThrow() : null; + + CanvasPane canvas = new CanvasPane(Game.WIDTH.px(), Game.HEIGHT.px()); + this.gc = canvas.getGraphicsContext2D(); + HBox canvasBox = new HBox(); + canvasBox.setAlignment(Pos.CENTER); + canvasBox.setPrefWidth(this.getCenterWidth()); + canvasBox.getChildren().add(canvas); + this.pane.setCenter(canvasBox); + + this.scene.setOnKeyPressed(event -> this.keys.put(event.getCode(), true)); + + var superOnKeyReleased = this.scene.getOnKeyReleased(); + this.scene.setOnKeyReleased(event -> { + this.keys.put(event.getCode(), false); + + switch (event.getCode()) { + case F3 -> Client.the().setDebugMode(!Client.the().isDebugMode()); + case F5 -> this.interpolMinSpeed = (this.interpolMinSpeed + 1) % 11; + } + + superOnKeyReleased.handle(event); + }); + } + + public Game getGame() { + return this.game; + } + + public void setGame(Game game) { + this.game = game; + } + + private void drawFloor() { + gc.drawImage(Images.get(Images.FLOOR), 0, 0, Game.WIDTH.px(), Game.HEIGHT.px()); + + if (Client.the().isDebugMode()) { + this.gc.setFill(Color.GREEN); + this.gc.setGlobalAlpha(0.1); + this.gc.fillRect(Game.PLAYABLE_AREA.getLeft().px(), Game.PLAYABLE_AREA.getTop().px(), + Game.PLAYABLE_AREA.getWidth().px(), + Game.PLAYABLE_AREA.getHeight().px()); + this.gc.setGlobalAlpha(1); + this.gc.setStroke(Color.GREEN); + this.gc.strokeRect(Game.PLAYABLE_AREA.getLeft().px(), Game.PLAYABLE_AREA.getTop().px(), + Game.PLAYABLE_AREA.getWidth().px(), + Game.PLAYABLE_AREA.getHeight().px()); + } + } + + private void drawHeader() { + this.gc.setTextBaseline(VPos.CENTER); + + /* + * Mode. + */ + if (Client.the().getOwnActor().member().isPresent() && Client.the().getOwnActor().member() + .orElseThrow().player().isEmpty()) { + this.gc.setTextAlign(TextAlignment.LEFT); + this.gc.setFont(Fonts.get(Fonts.GAME_SUBTITLE)); + this.gc.setFill(Color.DARKGRAY); + this.gc.fillText("Spectating", Workbench.SIZE.px(), Workbench.SIZE.px() / 1.75); + } + + /* + * Time remaining. + */ + int ticks = this.game.ticksUntilGameOver.get(); + int minutes = Math.max(0, ticks / Game.TPS / 60); + int seconds = Math.max(0, ticks / Game.TPS % 60); + + this.gc.setTextAlign(TextAlignment.CENTER); + this.gc.setFont(Fonts.get(Fonts.GAME_TITLE)); + this.gc.setFill(Color.SILVER); + this.gc.fillText( + Client.the().isDebugMode() ? String.valueOf(ticks) + : String.format("%02d:%02d", minutes, seconds), + Game.WIDTH.px() / 2, Workbench.SIZE.px() / 2.25); + + /* + * Score. + */ + this.gc.setTextAlign(TextAlignment.RIGHT); + this.gc.setFont(Fonts.get(Fonts.GAME_TITLE)); + this.gc.setFill(Color.GREEN); + this.gc.fillText(String.valueOf(this.game.getScore()), + Game.WIDTH.px() - Workbench.SIZE.px(), + Workbench.SIZE.px() / 2.25); + } + + /** + * Renders the game view, including the kitchen floor, workbenches, orders, players, and game + * timer. + */ + @Override + void draw() { + drawFloor(); + + drawHeader(); + + /* + * Draw workbenches. + */ + for (Workbench workbench : this.game.workbenches) { + workbench.draw(this.gc); + } + + /* + * Draw players. + */ + for (Actor actor : this.game.getLobby().getActors()) { + if (this.game.getTutorialPlayer() == null + && actor.member().orElseThrow().player().isEmpty()) { + continue; + } + + Player player; + if (this.game.getTutorialPlayer() != null) { + player = this.game.getTutorialPlayer(); + } else { + player = actor.member().orElseThrow().player().orElseThrow(); + } + + /* + * Interpolate movement. + */ + if (!actor.getIdentifier().equals(Client.the().getOwnIdentifier())) { + final double framesPerPositionTick = 1d * ClientGUI.FPS / Game.POSITION_TPS; + final double framesPerTick = 1d * ClientGUI.FPS / Game.TPS; + final double unitsPerFrame = Game.UNITS_PER_TICK.u() / framesPerTick; + final double unitsPerPositionTick = unitsPerFrame * framesPerPositionTick; + final double unitsMoved = Coords.distance(player.getPreviousCoords(), + player.getRealCoords()).u(); + final double speedMultiplier = Math.max(interpolMinSpeed / 10d, + unitsMoved / unitsPerPositionTick); + final double unitsAbleToMove = speedMultiplier * unitsPerFrame; + + final Vector displacement = Vector.between(player.getRect(), + player.getRealCoords()) + .withMagnitude(new Units(unitsAbleToMove)); + + if (unitsAbleToMove >= Coords.distance(player.getRect(), + player.getRealCoords()) + .u()) { + player.getRect().setX(player.getRealCoords().getX()); + player.getRect().setY(player.getRealCoords().getY()); + } else { + player.getRect().displace(displacement); + } + + /* + * Draw displacement. + */ + if (Client.the().isDebugMode()) { + this.gc.setStroke(Color.RED); + this.gc.setLineWidth(new Units(0.05).px()); + this.gc.strokeLine(player.getRect().getX().px(), player.getRect().getY().px(), + player.getRect().getX().px() + displacement.getX().px() * 10, + player.getRect().getY().px() + displacement.getY().px() * 10); + } + } + + /* + * Determine which direction the player is facing. + */ + double vx, vy; + if (actor.getIdentifier().equals(Client.the().getOwnIdentifier())) { + vx = + (keys.getOrDefault(KeyCode.D, false) ? 1 : 0) - ( + keys.getOrDefault(KeyCode.A, false) + ? 1 : 0); + vy = + (keys.getOrDefault(KeyCode.S, false) ? 1 : 0) - ( + keys.getOrDefault(KeyCode.W, false) + ? 1 : 0); + } else { + var displacement = Vector.between(player.getPreviousCoords(), + player.getRealCoords()); + vx = displacement.getX().u(); + vy = displacement.getY().u(); + } + + Facing facing = null; + if (vy < 0 && vx == 0) { + facing = Facing.NORTH; + } + if (vy < 0 && vx > 0) { + facing = Facing.NORTHEAST; + } + if (vy == 0 && vx > 0) { + facing = Facing.EAST; + } + if (vy > 0 && vx > 0) { + facing = Facing.SOUTHEAST; + } + if (vy > 0 && vx == 0) { + facing = Facing.SOUTH; + } + if (vy > 0 && vx < 0) { + facing = Facing.SOUTHWEST; + } + if (vy == 0 && vx < 0) { + facing = Facing.WEST; + } + if (vy < 0 && vx < 0) { + facing = Facing.NORTHWEST; + } + + if (facing != null) { + player.setFacing(facing); + player.setMoving(true); + } else { + player.setMoving(false); + } + + player.draw(this.gc, actor.record().orElseThrow().getNickname()); + } + + if (Client.the().isDebugMode()) { + /* + * Draw barriers. + */ + for (var barrier : this.game.barriers) { + this.gc.setFill(Color.RED); + this.gc.setGlobalAlpha(0.1); + this.gc.fillRect(barrier.getLeft().px(), barrier.getTop().px(), + barrier.getWidth().px(), + barrier.getHeight().px()); + this.gc.setGlobalAlpha(1); + this.gc.setStroke(Color.RED); + this.gc.strokeRect(barrier.getLeft().px(), barrier.getTop().px(), + barrier.getWidth().px(), + barrier.getHeight().px()); + } + + /* + * Draw experimental options. + */ + this.gc.setFont(Fonts.get(Fonts.GAME_DEBUG)); + this.gc.setFill(Color.BLACK); + this.gc.setTextAlign(TextAlignment.LEFT); + this.gc.setTextBaseline(VPos.TOP); + this.gc.fillText( + "[F5] Interpol. Min. Speed: " + this.interpolMinSpeed / 10d, 0, 30); + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/LobbiesView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/LobbiesView.java new file mode 100644 index 0000000..77ba9f7 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/LobbiesView.java @@ -0,0 +1,240 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Actor; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Lobby; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; + +/** + * Represents the graphical user interface (GUI) for displaying available lobbies in the Let Us Cook + * application. Users can join existing lobbies or create new ones through this interface. + */ +class LobbiesView extends View { + + private final Label welcome; + + private final Map actorLabelMap = new HashMap<>(); + + private final VBox actorList; + + /** + * The container for holding lobby buttons. + */ + private final VBox lobbyList; + + /** + * Mapping between lobbies and their corresponding buttons. + */ + private final Map lobbyNodeMap = new HashMap<>(); + + private final Map lobbyGameIsRunningMap = new HashMap<>(); + + /** + * Constructs a new {@code LobbiesView} with the specified main stage. + * + * @param stage the main stage of the application + */ + LobbiesView(Stage stage) { + super( + stage, + Views.LOBBIES, + new BorderPane(), + "Let Us Cook!", + Images.get(Images.BACKGROUND), + Images.LEAVE, + () -> Client.the().tryDisconnect(), + "Disconnect" + ); + + /* + * Left-hand side. + */ + + var leftHandSide = new VBox(); + leftHandSide.setPrefWidth(this.getCenterWidth() / 2); + leftHandSide.setPadding(new Insets(100)); + + this.welcome = new Label(); + welcome.setWrapText(true); + welcome.prefWidthProperty().bind(leftHandSide.widthProperty()); + welcome.setFont(Fonts.get(Fonts.UI_TITLE)); + welcome.setStyle("-fx-text-fill: white;"); + welcome.setPadding(new Insets(0, 0, 50, 0)); + + Label introduction = new Label( + "Welcome to Let Us Cook! Use the sidebar to your left to change your nickname, navigate through menus, and access additional information. To start playing, create a new lobby or join an existing one.\n\nIf this is your first time here, check out the tutorial to learn the basics:"); + introduction.setWrapText(true); + introduction.prefWidthProperty().bind(leftHandSide.widthProperty()); + introduction.setFont(Fonts.get(Fonts.UI_PRIMARY)); + introduction.setStyle("-fx-text-fill: white;"); + introduction.setPadding(new Insets(0, 0, 50, 0)); + + var tutorial = Controls.primaryButton("Play Tutorial", + () -> ClientGUI.changeRequestedView(Views.TUTORIAL)); + var tutorialBox = new HBox(tutorial); + tutorialBox.setAlignment(Pos.CENTER); + + Label online = new Label("Online:"); + online.setFont(Fonts.get(Fonts.UI_PRIMARY)); + online.setStyle("-fx-text-fill: white;"); + online.setPadding(new Insets(50, 0, 25, 0)); + + this.actorList = new VBox(10); + + leftHandSide.getChildren() + .addAll(this.welcome, introduction, tutorialBox, online, this.actorList); + leftHandSide.setAlignment(Pos.TOP_LEFT); + + /* + * Right-hand side. + */ + + var rightHandSide = new VBox(); + rightHandSide.setPrefWidth(this.getCenterWidth() / 2); + rightHandSide.setPadding(new Insets(100)); + + Label lobbies = new Label("Get to cooking!"); + lobbies.setWrapText(true); + lobbies.prefWidthProperty().bind(leftHandSide.widthProperty()); + lobbies.setFont(Fonts.get(Fonts.UI_TITLE)); + lobbies.setStyle("-fx-text-fill: white;"); + lobbies.setPadding(new Insets(0, 0, 50, 0)); + + var open = Controls.primaryButton("New Lobby...", () -> ClientGUI.the().showLobbyDialog()); + open.setPadding(new Insets(0, 0, 50, 0)); + + Label openLobbies = new Label("Open lobbies:"); + openLobbies.setWrapText(true); + openLobbies.prefWidthProperty().bind(leftHandSide.widthProperty()); + openLobbies.setFont(Fonts.get(Fonts.UI_PRIMARY)); + openLobbies.setStyle("-fx-text-fill: white;"); + openLobbies.setPadding(new Insets(0, 0, 25, 0)); + + this.lobbyList = new VBox(5); + + rightHandSide.getChildren().addAll(lobbies, open, openLobbies, this.lobbyList); + rightHandSide.setAlignment(Pos.TOP_CENTER); + + /* + * Center. + */ + + this.pane.setCenter(new HBox(leftHandSide, rightHandSide)); + + var superOnKeyReleased = this.scene.getOnKeyReleased(); + this.scene.setOnKeyReleased(event -> { + if (event.getCode() == KeyCode.O) { + ClientGUI.the().showLobbyDialog(); + } + superOnKeyReleased.handle(event); + }); + } + + /** + * Updates the display of lobbies. + */ + @Override + void draw() { + if (Client.the().hasOwnActor() && Client.the().getOwnActor().record().isPresent()) { + this.welcome.setText( + "Welcome, " + Client.the().getOwnActor().record().orElseThrow().getNickname() + + "!"); + } + + /* + * Update the actors. + */ + var actors = Client.the().getActors(); + + /* Drop actors that no longer exist. */ + this.actorLabelMap.keySet().retainAll(List.of(actors)); + + for (var actor : actors) { + var recordOrEmpty = actor.record(); + if (recordOrEmpty.isEmpty()) { + continue; + } + + Label name = this.actorLabelMap.get(actor); + + /* Create a new label. */ + if (name == null) { + name = new Label(); + name.setFont(Fonts.get(Fonts.UI_PRIMARY)); + name.setTextFill(Color.WHITE); + + this.actorLabelMap.put(actor, name); + this.actorList.getChildren().add(name); + } + + /* Update the label. */ + if (!name.getText().equals(recordOrEmpty.get().getNickname())) { + name.setText(recordOrEmpty.get().getNickname()); + } + } + this.actorList.getChildren().retainAll(this.actorLabelMap.values()); + this.actorList.setAlignment(Pos.CENTER); + + /* + * Update the lobby list. + */ + var lobbies = Client.the().getLobbies(); + + /* Drop lobbies that no longer exist. */ + this.lobbyNodeMap.keySet().retainAll(List.of(lobbies)); + this.lobbyGameIsRunningMap.keySet().retainAll(List.of(lobbies)); + + for (var lobby : lobbies) { + var existingNode = lobbyNodeMap.get(lobby); + + int nodeIndex = -1; /* If this lobby is new, we'll add a new node for it instead of replacing the old one. */ + if (existingNode != null) { + var gameStateHasChanged = lobby.gameIsRunning() != lobbyGameIsRunningMap.get(lobby); + + if (gameStateHasChanged) { + /* If we've already recorded this lobby, but its state has changed, grab the node index to replace it. */ + nodeIndex = this.lobbyList.getChildren().indexOf(existingNode); + } else { + /* If we've already recorded this lobby and its state is unchanged, skip it. */ + continue; + } + } + + /* Create the join button. */ + boolean gameIsRunning = lobby.gameIsRunning(); + lobbyGameIsRunningMap.put(lobby, gameIsRunning); + var join = Controls.bigButton( + gameIsRunning ? Images.LOBBY_BUTTON_ACTIVE : Images.LOBBY_BUTTON_IDLE, + -10, + lobby.getName(), () -> { + Sounds.CLICK.play(); + Client.the().tryJoinLobby(lobby.getName()); + }); + lobbyNodeMap.put(lobby, join); + + /* Add or replace the node. */ + if (nodeIndex == -1) { + this.lobbyList.getChildren().add(join); + } else { + this.lobbyList.getChildren().set(nodeIndex, join); + } + } + this.lobbyList.getChildren().retainAll(this.lobbyNodeMap.values()); + this.lobbyList.setAlignment(Pos.CENTER); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/LobbyView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/LobbyView.java new file mode 100644 index 0000000..9865f82 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/LobbyView.java @@ -0,0 +1,183 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Actor; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; + +/** + * The lobby view of the Let Us Cook application, displaying lobby information and player list. + */ +class LobbyView extends View { + + private final HBox memberList = new HBox(25); + + private final Map actorNodeMap = new HashMap<>(); + + private final Map actorNicknameMap = new HashMap<>(); + + private final Map actorReadyMap = new HashMap<>(); + + private final HBox readyButtonContainer = new HBox(); + + private Button readyButton = null; + + private ReadyState readyState = ReadyState.NOT_READY; + + private Label latestScore = null; + + /** + * Constructs the LobbyView with the specified stage. + * + * @param stage The stage for the view. + */ + LobbyView(Stage stage) { + super( + stage, + Views.LOBBY, + new BorderPane(), + 800d, 600d, + "Let Us Cook!", + Images.get(Images.BACKGROUND), + Images.LEAVE, + () -> Client.the().tryLeaveLobby(), + "Leave Lobby" + ); + + var name = new Label( + Client.the().getOwnActor().member().orElseThrow().getLobby().getName()); + name.setFont(Fonts.get(Fonts.UI_TITLE)); + name.setTextFill(Color.WHITE); + name.setPadding(new Insets(0, 0, 50, 0)); + + this.latestScore = new Label(); + latestScore.setWrapText(true); + latestScore.setFont(Fonts.get(Fonts.UI_PRIMARY)); + latestScore.setStyle("-fx-text-fill: white;"); + latestScore.setPadding(new Insets(0, 0, 50, 0)); + + var overview = new VBox(50, name, this.latestScore, memberList, readyButtonContainer); + overview.setAlignment(Pos.CENTER); + this.pane.setCenter(overview); + } + + @Override + void draw() { + /* Ensure the client is currently in a lobby. */ + var memberOrEmpty = Client.the().getOwnActor().member(); + if (memberOrEmpty.isEmpty()) { + return; + } + + /* + * Update the score. + */ + var latestScoreOrEmpty = memberOrEmpty.get().getLobby().getClientLatestScore(); + latestScoreOrEmpty.ifPresent( + score -> this.latestScore.setText("Game over! You scored " + score + " points!")); + + /* + * Update the member list. + */ + var actors = memberOrEmpty.orElseThrow().getLobby().getActors(); + + /* Drop actors that left the lobby. */ + actorNodeMap.keySet().retainAll(List.of(actors)); + actorReadyMap.keySet().retainAll(List.of(actors)); + actorNicknameMap.keySet().retainAll(List.of(actors)); + + for (Actor actor : actors) { + var member = actor.member(); + var record = actor.record(); + if (member.isEmpty() || record.isEmpty()) { + continue; + } + var existingNode = actorNodeMap.get(actor); + + int nodeIndex = -1; /* If this actor is new, we'll add a new node for it instead of replacing the old one. */ + if (existingNode != null) { + var readyStateHasChanged = member.get().isReady() != actorReadyMap.get(actor); + + var nameHasChanged = !record.get().getNickname() + .equals(actorNicknameMap.get(actor)); + + if (readyStateHasChanged || nameHasChanged) { + /* If we've already recorded this actor, but its state has changed, grab the node index to replace it. */ + nodeIndex = this.memberList.getChildren().indexOf(existingNode); + } else { + /* If we've already recorded this actor and its state is unchanged, skip it. */ + continue; + } + } + + /* Create the presence. */ + boolean ready = member.get().isReady(); + actorReadyMap.put(actor, ready); + ImageView model = new ImageView( + Images.get(ready ? Images.MEMBER_READY : Images.MEMBER_ASLEEP)); + + String nickname = record.get().getNickname(); + actorNicknameMap.put(actor, nickname); + Label nameTag = new Label(nickname); + nameTag.setFont(Fonts.get(Fonts.UI_PRIMARY)); + nameTag.setTextFill(Color.WHITE); + + var presence = new VBox(25); + presence.setAlignment(Pos.CENTER); + presence.getChildren().addAll(model, nameTag); + actorNodeMap.put(actor, presence); + + /* Add or replace the node. */ + if (nodeIndex == -1) { + this.memberList.getChildren().add(presence); + } else { + this.memberList.getChildren().set(nodeIndex, presence); + } + } + this.memberList.getChildren().retainAll(this.actorNodeMap.values()); + this.memberList.setAlignment(Pos.CENTER); + + /* + * 'Ready' button. + */ + var newReadyState = + memberOrEmpty.get().getLobby().isEveryoneReady() ? ReadyState.EVERYONE_READY + : memberOrEmpty.get().isReady() ? ReadyState.READY : ReadyState.NOT_READY; + if (this.readyButton == null || newReadyState != this.readyState) { + this.readyState = newReadyState; + this.readyButton = Controls.primaryButton( + readyState != ReadyState.EVERYONE_READY ? "Ready" : "Start", + readyState == ReadyState.READY ? null : () -> { + if (Client.the().getOwnActor().member().isPresent()) { + Sounds.CLICK.play(); + Client.the().sendReady(); + Client.the().tryStartGame(); + } + }); + this.readyButtonContainer.getChildren().clear(); + this.readyButtonContainer.getChildren().add(readyButton); + this.readyButtonContainer.setAlignment(Pos.CENTER); + } + } + + private enum ReadyState { + NOT_READY, + READY, + EVERYONE_READY, + ; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/RecipesView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/RecipesView.java new file mode 100644 index 0000000..4b880b3 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/RecipesView.java @@ -0,0 +1,175 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.orders.ChoppingWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.FryerWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.GrillWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Order; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.OvenWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Recipe; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Workbench; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Coords; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Identifier; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Resource; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; + +/** + * The RecipesView class represents the graphical user interface (GUI) for displaying recipes in the + * Let Us Cook game. + */ +public class RecipesView extends View { + + private static final double listSpacing = 10; + + Image grillImage = new Image(Resource.get("workbench/grill_active.png"), + Order.SIZE.px(), Order.SIZE.px(), true, false); + + Image ovenImage = new Image(Resource.get("workbench/oven_active.png"), + Order.SIZE.px(), Order.SIZE.px(), true, false); + + Image fryerImage = new Image(Resource.get("workbench/fryer_active.png"), + Order.SIZE.px(), Order.SIZE.px(), true, false); + + Image choppingImage = new Image(Resource.get("workbench/chopping_active.png"), + Order.SIZE.px(), Order.SIZE.px(), true, false); + + /** + * Constructs a new RecipesView object with the specified stage. + * + * @param stage The stage for the view. + */ + public RecipesView(Stage stage) { + super(stage, + Views.RECIPES, + new BorderPane(), + 800d, 600d, + "Let Us Cook!", + Images.get(Images.BACKGROUND), + Images.RETURN, + () -> ClientGUI.changeRequestedPopUpView(null), + "Back" + ); + + var orderList = new HBox(listSpacing); + + for (var order : Order.values()) { + var parts = new VBox(); + + var stack = order.stack().toArray(); + for (int i = 0; i < stack.length; ++i) { + parts.getChildren().add(this.object(stack[i].getImage())); + if (i < stack.length - 1) { + parts.getChildren().add(this.plusSymbol()); + } + } + + parts.getChildren().addAll(this.actionSymbol(), this.object(order.getImage())); + parts.setAlignment(Pos.TOP_CENTER); + parts.setStyle("-fx-background-color: rgba(30, 30, 50, 0.5);"); + + orderList.getChildren().add(parts); + } + + var transformationLists = new HBox(50); + + transformationLists.getChildren().addAll( + this.transformationList(grillImage, new GrillWorkbench(Identifier.NONE, + new Coords(new Units(0), new Units(0))).getRecipes()), + this.transformationList(fryerImage, new FryerWorkbench(Identifier.NONE, + new Coords(new Units(0), new Units(0))).getRecipes()), + this.transformationList(choppingImage, new ChoppingWorkbench(Identifier.NONE, + new Coords(new Units(0), new Units(0))).getRecipes()), + this.transformationList(ovenImage, new OvenWorkbench(Identifier.NONE, + new Coords(new Units(0), new Units(0))).getRecipes()) + ); + + var panels = new HBox(50, transformationLists, orderList); + + this.pane.setCenter(panels); + } + + /** + * Create an ImageView for the given image. + * + * @param image The image to be displayed. + * @return The created ImageView. + */ + private ImageView object(Image image) { + var imageView = new ImageView(image); + imageView.setFitWidth(Workbench.SIZE.px() / 2); + return imageView; + } + + /** + * Create a Label for the plus symbol. + * + * @return The created Label. + */ + private Label plusSymbol() { + var label = new Label("+"); + label.setAlignment(Pos.CENTER); + label.setFont(Fonts.get(Fonts.UI_PRIMARY)); + label.setTextFill(Color.WHITE); + label.setMaxHeight(20); + label.setMinHeight(20); + label.setPrefHeight(20); + return label; + } + + /** + * Create a Label for the action symbol. + * + * @return The created Label. + */ + private Label actionSymbol() { + var label = new Label("\\/"); + label.setAlignment(Pos.CENTER); + label.setFont(Fonts.get(Fonts.UI_PRIMARY)); + label.setTextFill(Color.WHITE); + label.setPadding(new Insets(20, 0, 20, 0)); + return label; + } + + /** + * Create a transformation list for the given workbench and recipes. + * + * @param transformer The image representing the workbench. + * @param recipes The recipes for the workbench. + * @return The HBox containing the transformation list. + */ + private HBox transformationList(Image transformer, Recipe[] recipes) { + var transformationList = new HBox(listSpacing); + + for (var recipe : recipes) { + var parts = new VBox(); + + var stack = recipe.items().toArray(); + for (int i = 0; i < stack.length; ++i) { + parts.getChildren().add(this.object(stack[i].getImage())); + if (i < stack.length - 1) { + parts.getChildren().add(this.plusSymbol()); + } + } + + parts.getChildren() + .addAll(this.actionSymbol(), this.object(transformer), this.actionSymbol(), + this.object(recipe.result().reduceToSingleItem().getImage())); + parts.setAlignment(Pos.TOP_CENTER); + parts.setStyle("-fx-background-color: rgba(30, 30, 50, 0.5);"); + + transformationList.getChildren().add(parts); + } + + return transformationList; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/SplashView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/SplashView.java new file mode 100644 index 0000000..0e539c5 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/SplashView.java @@ -0,0 +1,32 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; + +/** + * The SplashView class represents the graphical user interface (GUI) for the splash screen in the + * Let Us Cook game. + */ +public class SplashView extends View { + + /** + * Constructs a new SplashView object with the specified stage. + * + * @param stage The stage for the view. + */ + public SplashView(Stage stage) { + super( + stage, + Views.SPLASH, + new BorderPane(), + 800d, 600d, + "Let Us Cook!", + null, + null, null, null + ); + + this.pane.setCenter(new ImageView(Images.get(Images.LOGO))); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/StartView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/StartView.java new file mode 100644 index 0000000..9839433 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/StartView.java @@ -0,0 +1,112 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.MalformedException; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Messenger; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedName; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.util.Optional; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonBar.ButtonData; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +/** + * The start view of the Let Us Cook application, displaying welcome message and login options. + */ +class StartView extends View { + + /** + * Constructs the StartView with the specified stage. + * + * @param stage The stage for the view. + */ + StartView(Stage stage) { + super( + stage, + Views.START, + new VBox(), + "Let Us Cook!", + Images.get(Images.BACKGROUND), + null, + null, + null + ); + + /* + * Logo. + */ + var logo = new ImageView(Images.get(Images.LOGO)); + logo.setPreserveRatio(true); + logo.setFitHeight(200); + + /* + * Subtitle. + */ + Label subtitle = new Label( + "Choose a name:"); + subtitle.setWrapText(true); + subtitle.setFont(Fonts.get(Fonts.UI_PRIMARY)); + subtitle.setStyle("-fx-text-fill: white;"); + subtitle.setPadding(new Insets(0, 0, 0, 0)); + + /* + * Nickname field. + */ + var nickname = Controls.textArea(new Units(2.5).px(), new Units(0.4).px(), + Client.the().getLoginNickname().toString(), + false, null, SanitizedName::canContain, SanitizedName.MAX_LENGTH); + + /* + * Start button. + */ + var start = Controls.primaryButton("Let's Go!", () -> { + nickname.requestFocus(); + Sounds.CLICK.play(); + try { + Client.the().setLoginNickname( + SanitizedName.createOrThrow(nickname.getText())); + } catch (MalformedException e) { + Messenger.warn("Ignored Malformed login nickname"); + } + Client.the().tryConnect(); + }); + + /* + * Exit button. + */ + var exit = Controls.secondaryButton("Quit Game", () -> { + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Exiting Let Us Cook!"); + alert.setHeaderText(null); + alert.setContentText("Exit the game?"); + + ButtonType exitYesButton = new ButtonType("OK"); + ButtonType exitNoButton = new ButtonType("Cancel", ButtonData.CANCEL_CLOSE); + alert.getButtonTypes().setAll(exitYesButton, exitNoButton); + + Optional result = alert.showAndWait(); + if (result.isPresent() && result.orElseThrow() == exitYesButton) { + System.exit(1); + } + }); + + var nameAndStartBox = new VBox(25, subtitle, nickname, start); + nameAndStartBox.setAlignment(Pos.CENTER); + + var buttons = new VBox(50, nameAndStartBox, exit); + buttons.setAlignment(Pos.CENTER); + + var vbox = new VBox(75, logo, buttons); + vbox.prefHeightProperty().bind(this.getScene().heightProperty()); + vbox.setAlignment(Pos.CENTER); + this.pane.getChildren().addAll(vbox); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/TutorialView.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/TutorialView.java new file mode 100644 index 0000000..e80849b --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/TutorialView.java @@ -0,0 +1,357 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.CustomerWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.GrillWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Item; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.ItemWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Order; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.PlateWorkbench; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Stack; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.State; +import ch.unibas.dmi.dbis.cs108.letuscook.orders.Workbench; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Game; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Lobby; +import ch.unibas.dmi.dbis.cs108.letuscook.server.Player; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import java.util.Optional; +import java.util.function.Consumer; +import javafx.application.Platform; +import javafx.event.EventHandler; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.DialogPane; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.stage.Stage; + +/** + * The TutorialView class represents the graphical user interface (GUI) for the tutorial section in + * the Let Us Cook game. + */ +public class TutorialView extends KitchenView { + + private final EventHandler superOnKeyReleased; + CustomerWorkbench tutorialBurgerWorkbench; + private boolean wPressed = false; + private boolean aPressed = false; + private boolean sPressed = false; + private boolean dPressed = false; + + /** + * Constructs a new TutorialView object with the specified stage. + * + * @param stage The stage for the view. + */ + TutorialView(Stage stage) { + super( + stage, + Views.TUTORIAL, + Images.LEAVE, + () -> { + ((TutorialView) ClientGUI.the().getActiveView()).getGame().stop(); + ClientGUI.changeRequestedView(Views.LOBBIES); + }, + "Back" + ); + + this.superOnKeyReleased = this.scene.getOnKeyReleased(); + + var lobby = new Lobby(false, "Tutorial"); + lobby.addMember(Client.the().getOwnActor()); + var game = new Game(lobby, 0, new Player()); + this.setGame(game); + + Platform.runLater(() -> { + createAndShowInformationAlert("Welcome!", + "Welcome in the kitchen! I'll give you a moment to get familiar with your surroundings.\nUse W, A, S, D to to move around.", + true); + + this.setConsumer(this::firstStep); + }); + } + + private void setConsumer(Consumer step) { + this.scene.setOnKeyReleased(event -> { + step.accept(event.getCode()); + + this.superOnKeyReleased.handle(event); + }); + } + + /** + * Executes the first step of the tutorial based on the provided KeyCode. + * + * @param code The KeyCode representing the key pressed by the user. + */ + + // check if the player pressed all wasd keys at least once + private void firstStep(KeyCode code) { + switch (code) { + case W -> this.wPressed = true; + case A -> this.aPressed = true; + case S -> this.sPressed = true; + case D -> this.dPressed = true; + } + + if (this.wPressed && this.aPressed && this.sPressed && this.dPressed) { + + for (Workbench workbenches : this.getGame().workbenches) { + if (workbenches instanceof CustomerWorkbench customerWorkbench) { + this.tutorialBurgerWorkbench = customerWorkbench; + customerWorkbench.forceSetState(State.ACTIVE); + customerWorkbench.forceSetOrder(Order.HAMBURGER); + break; + } + } + + createAndShowInformationAlert("New order!", + "No time to waste, your first order just came in! Let's prepare that hamburger!", + true); + + createAndShowInformationAlert("Get to cooking!", + "Move to the counter below and use E or SPACE to pick up a patty.", + true); + + this.setConsumer(this::secondStep); + } + } + + // get the raw patty from the itemworkbench + private void secondStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof ItemWorkbench) { + if (workbench.get().peekContents().equals(Stack.of(Item.RAW_PATTY))) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + createAndShowInformationAlert("Grill it!", + "Next, place the patty on the grill and wait for it to finish cooking.", + true); + + this.setConsumer(this::thirdStep); + } + } + } + } + } + } + + //put the raw patty on the grill + private void thirdStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof GrillWorkbench && this.getGame() + .getTutorialPlayer().getHolding().equals(Stack.of(Item.RAW_PATTY))) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + Thread pattyThread = new Thread(() -> { + while (workbench.get().ticksUntilStateChange.get() > 0) { + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + workbench.get().forceSetState(State.FINISHED); + workbench.get() + .forceSetContentsAccordingToState(Stack.of(Item.GRILLED_PATTY)); + workbench.get().ticksUntilStateChange.set( + Order.HAMBURGER.getExpirationTimeSeconds() * Game.TPS); + + Platform.runLater(() -> { + createAndShowInformationAlert("Perfect!", + "Your patty is ready! Quickly remove it from the grill and place it on one of the plates in the middle.", + true); + + this.setConsumer(this::fourthStep); + }); + }, "pattyThread"); + + pattyThread.setDaemon(true); + pattyThread.start(); + + } + } + } + } + } + + //take the grilled patty from the grill + private void fourthStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof GrillWorkbench && this.getGame() + .getTutorialPlayer().getHolding().equals(Stack.of())) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + this.setConsumer(this::fifthStep); + } + } + } + } + } + + // put the grilled patty on the plate + private void fifthStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof PlateWorkbench && this.getGame() + .getTutorialPlayer().getHolding().equals(Stack.of(Item.GRILLED_PATTY))) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + createAndShowInformationAlert("Stay on your toes!", + "Remember: items burn if they're on the heat for too long! You can discard items in the bin in the top right.", + true); + + createAndShowInformationAlert("Almost there!", + "Let's finish off your burger! Grab some buns (bread) from the counter and add them to the plate.", + true); + + this.setConsumer(this::sixthStep); + } + } + } + } + } + + // take the bun from the itemworkbench + private void sixthStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof ItemWorkbench) { + if (workbench.get().peekContents().equals(Stack.of(Item.BREAD))) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + this.setConsumer(this::seventhStep); + } + } + } + } + } + } + + // put the bun on the plate with the patty + private void seventhStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof PlateWorkbench && this.getGame() + .getTutorialPlayer().getHolding().equals(Stack.of(Item.BREAD))) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + createAndShowInformationAlert("Well done!", + "Bravo! That burger's bursting with flavor! Now all there's left to do is to deliver it to our hungry customer!", + true); + + this.setConsumer(this::eighthStep); + } + } + } + } + } + + // take the burger + private void eighthStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof PlateWorkbench) { + if (workbench.get().peekContents() + .equals(Stack.of(Item.BREAD, Item.GRILLED_PATTY))) { + this.getGame() + .getTutorialPlayer().setHolding(workbench.get().trade(this.getGame() + .getTutorialPlayer().getHolding())); + + this.setConsumer(this::ninthStep); + } + } + } + } + } + } + + // serve the burger + private void ninthStep(KeyCode code) { + switch (code) { + case E, SPACE -> { + Optional workbench = this.getGame() + .getWorkbenchIfInReach(this.getGame().getTutorialPlayer()); + if (workbench.isPresent()) { + if (workbench.orElseThrow() instanceof CustomerWorkbench) { + this.getGame().getTutorialPlayer() + .setHolding(this.tutorialBurgerWorkbench.trade( + this.getGame().getTutorialPlayer().getHolding())); + this.getGame().forceSetScore(Order.HAMBURGER.getPrice()); + + createAndShowInformationAlert("Nice!", + "Hooray! You've completed your first order!", + true); + + createAndShowInformationAlert("Crack the highscore!", + "Since you delivered the burger in time, your score went up by " + + Order.HAMBURGER.getPrice() + + " points! Try to beat your friends' scores, or join them to conquer the leaderboard as a group!", + true); + + createAndShowInformationAlert("Huh?", + "If you're ever unsure about how an order is prepared, you can find all the instructions under 'Recipes' in the sidebar.", + true); + + createAndShowInformationAlert("Perfect!", + "I think you're ready! Let us cook!", true); + + this.returnButtonAction.run(); + } + } + } + } + } + + private void createAndShowInformationAlert(String title, String contentText, boolean wait) { + this.keys.clear(); + + Alert alert = new Alert(AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(contentText); + alert.getDialogPane().lookup(".content.label") + .setStyle("-fx-font-family: 'Arial'; -fx-font-size: 14px; -fx-text-fill: #333333;"); + + DialogPane alertPane = alert.getDialogPane(); + alertPane.setGraphic(new ImageView(Images.get(Images.ICON))); + + ClientGUI.the().showAlert(alert, wait); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/Units.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/Units.java new file mode 100644 index 0000000..7778237 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/Units.java @@ -0,0 +1,47 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +/** + * Represents units with a scale factor for conversions. + */ +public record Units(double u) { + + private static final double PRECISION = 1e5; + + private static double scale = 0; + + public Units(double u) { + this.u = Math.round(PRECISION * u) / PRECISION; + } + + /** + * Retrieves the current scale factor. + * + * @return The scale factor. + */ + private static double scale() { + return Units.scale; + } + + /** + * Sets the scale factor for conversions. + * + * @param scale The scale factor. + */ + public static void scale(final double scale) { + Units.scale = scale; + } + + /** + * Converts the units to pixels using the current scale factor. + * + * @return The converted units in pixels. + */ + public double px() { + return this.u() * Units.scale(); + } + + @Override + public String toString() { + return String.valueOf(this.u); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/View.java b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/View.java new file mode 100644 index 0000000..b506541 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/letuscook/gui/View.java @@ -0,0 +1,428 @@ +package ch.unibas.dmi.dbis.cs108.letuscook.gui; + +import ch.unibas.dmi.dbis.cs108.letuscook.client.Client; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Fonts; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Images; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Messenger; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Resource; +import ch.unibas.dmi.dbis.cs108.letuscook.util.SanitizedLine; +import ch.unibas.dmi.dbis.cs108.letuscook.util.Sounds; +import java.util.List; +import javafx.animation.FadeTransition; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextArea; +import javafx.scene.control.ToggleButton; +import javafx.scene.image.Image; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundImage; +import javafx.scene.layout.BackgroundPosition; +import javafx.scene.layout.BackgroundRepeat; +import javafx.scene.layout.BackgroundSize; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import javafx.util.Duration; + +/** + * Represents a generic view in the GUI. + * + * @param The type of the pane associated with the view. + */ +abstract public class View { + + private static VBox messageContainer; + private static ToggleButton chatButton; + private static VBox chatBox; + private static ScrollPane scrollPane; + private static TextArea chatInput; + private static VBox rightContainer; + final Stage stage; + final Views view; + final Scene scene; + final T pane; + final String title; + final Runnable returnButtonAction; + private final Images returnButtonIcon; + private final String returnButtonLabel; + + /** + * Constructs a new View with the specified parameters. + * + * @param stage The JavaFX stage associated with the view. + * @param view The type of view. + * @param pane The pane associated with the view. + * @param width The width of the scene. + * @param height The height of the scene. + * @param title The title of the view. + * @param backgroundUrlOrNull The background image for the view, or null. + */ + View( + final Stage stage, + final Views view, + final T pane, + final Double width, + final Double height, + final String title, + final Image backgroundUrlOrNull, + final Images returnButtonIcon, + final Runnable returnButtonAction, + final String returnButtonLabel + ) { + this.stage = stage; + this.view = view; + this.pane = pane; + this.scene = (width != null && height != null) ? new Scene(this.pane, width, height) + : new Scene(this.pane); + this.title = title; + this.returnButtonIcon = returnButtonIcon; + this.returnButtonAction = returnButtonAction; + this.returnButtonLabel = returnButtonLabel; + + this.scene.getStylesheets().add(Resource.get("style.css")); + + if (backgroundUrlOrNull != null) { + this.setBackground(backgroundUrlOrNull); + } + + this.initializeChat(); + + this.initializeSidebar(); + + this.scene.setOnKeyReleased(event -> { + switch (event.getCode()) { + case T -> { + if (ClientGUI.the().getActiveView() != null && this.view.showChat) { + this.focusChat(); + } + } + case F1 -> { + if (ClientGUI.the().getActiveView() != null && this.view.showChat) { + this.toggleChat(); + } + } + case M -> Sounds.BACKGROUND.getMediaPlayer() + .setMute(!Sounds.BACKGROUND.getMediaPlayer().isMute()); + case N -> Sounds.setMuteSoundEffects(!Sounds.getMuteSoundEffects()); + } + + event.consume(); + }); + } + + /** + * Constructs a new View with the specified parameters. + * + * @param stage The JavaFX stage associated with the view. + * @param view The type of view. + * @param pane The pane associated with the view. + * @param title The title of the view. + * @param backgroundUrlOrNull The URL of the background image for the view, or null. + */ + View( + final Stage stage, + final Views view, + final T pane, + final String title, + final Image backgroundUrlOrNull, + final Images returnButtonIcon, + final Runnable returnButtonAction, + final String returnButtonLabel + ) { + this(stage, view, pane, null, null, title, backgroundUrlOrNull, returnButtonIcon, + returnButtonAction, returnButtonLabel); + } + + public static void clearChat() { + messageContainer = null; + chatButton = null; + chatBox = null; + scrollPane = null; + chatInput = null; + rightContainer = null; + } + + public double getSidebarWidth() { + return 0.1 * ClientGUI.the().width; + } + + public double getChatWidth() { + return 0.2 * ClientGUI.the().width; + } + + public double getCenterWidth() { + return ClientGUI.the().width - this.getSidebarWidth() - this.getChatWidth(); + } + + public Scene getScene() { + return this.scene; + } + + public void initializeChat() { + if (!this.view.showChat) { + return; + } + + if (chatBox == null) { + createChat(); + } + + ((BorderPane) this.pane).setRight(rightContainer); + } + + public void createChat() { + rightContainer = new VBox(); + + final double sceneHeight = ClientGUI.the().height; + final double toggleHeight = 0.03 * sceneHeight; + final double tipHeight = 0.08 * sceneHeight; + final double inputHeight = 0.1 * sceneHeight; + + chatButton = new ToggleButton(null); + chatButton.setSelected(false); + chatButton.setPrefHeight(toggleHeight); + chatButton.setOnAction(event -> { + FadeTransition fadeTransition = new FadeTransition(Duration.millis(500), chatBox); + fadeTransition.setNode(chatBox); + fadeTransition.setFromValue(0.0); + fadeTransition.setToValue(1.0); + + if (chatButton.isSelected()) { + fadeTransition.setRate(-1); + fadeTransition.play(); + chatButton.setBackground(new Background(new BackgroundImage( + Images.get(Images.VISIBLE_SYMBOL), + BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, + BackgroundPosition.CENTER, new BackgroundSize( + BackgroundSize.AUTO, BackgroundSize.AUTO, false, false, true, false)))); + chatButton.setText(null); + } else { + fadeTransition.setRate(1); + fadeTransition.play(); + chatButton.setBackground(new Background(new BackgroundImage( + Images.get(Images.INVISIBLE_SYMBOL), + BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, + BackgroundPosition.CENTER, new BackgroundSize( + BackgroundSize.AUTO, BackgroundSize.AUTO, false, false, true, false)))); + chatButton.setText(null); + } + }); + this.toggleChat(); /* This is necessary. */ + this.toggleChat(); /* I know. */ + + chatBox = new VBox(); + chatBox.setStyle("-fx-background-color: #222034"); + + // Chat-Nachrichtenfenster + if (messageContainer == null) { + messageContainer = new VBox(); + var filler = new Region(); + VBox.setVgrow(filler, Priority.ALWAYS); + messageContainer.getChildren().add(filler); + messageContainer.setPrefHeight(1000000); + messageContainer.setStyle("-fx-background-color: transparent"); + messageContainer.heightProperty() + .addListener( + (observable -> Platform.runLater(() -> scrollPane.setVvalue(1.0)))); + } + + scrollPane = new ScrollPane(messageContainer); + scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent"); + Platform.runLater(() -> { + try { + scrollPane.lookup(".scroll-bar").setStyle("-fx-background-color: transparent;"); + scrollPane.lookup(".scroll-bar .thumb") + .setStyle("-fx-background-color: transparent;"); + scrollPane.lookup(".scroll-bar .increment-button") + .setStyle("-fx-background-color: transparent;"); + scrollPane.lookup(".scroll-bar .decrement-button") + .setStyle("-fx-background-color: transparent;"); + } catch (NullPointerException ignored) { + } + }); + scrollPane.setFitToWidth(true); + + //Fokus auf den chatcontainer lenken + ClientGUI.the().runLater(messageContainer::requestFocus); + + // Eingabefeld zum Chatten + chatInput = Controls.textArea(this.getChatWidth(), inputHeight, "Message...", true, + code -> { + switch (code) { + case ESCAPE -> { + chatInput.getParent().requestFocus(); + } + case ENTER -> { + String message = chatInput.getText().trim(); + if (ClientGUI.the().getActiveView().view.alwaysYell) { + message += "!"; + } + if (!message.isEmpty()) { + if (message.startsWith("@")) { // Whisper Chat Kürzel + String[] whisperMessage = message.split(" ", 2); + if (whisperMessage.length >= 2) { + String recipient = whisperMessage[0].substring(1); + String content = whisperMessage[1]; + Client.the().tryWhisperViaNickname(recipient, content); + } else { + Messenger.user("Malformed whisper message."); + } + } else if (message.endsWith("!")) { //Global Chat + Client.the().tryYell(message.substring(0, message.length() - 1)); + } else { + Client.the().tryChat(message); // nur in lobbys + } + chatInput.clear(); + if (!ClientGUI.the().getActiveView().view.keepChatFocused) { + chatInput.getParent().requestFocus(); + } + } + } + } + }, SanitizedLine::canContain, 0); + + Label tip = new Label( + "Use @name to whisper. If your message concerns everyone on the server, end it with an exclamation mark (!)."); + tip.setStyle("-fx-background-color: #18162a; -fx-text-fill: #524c83;"); + tip.setFont(Fonts.get(Fonts.UI_SECONDARY)); + tip.setPadding(new Insets(5, 10, 5, 10)); + tip.setMinHeight(tipHeight); + tip.setWrapText(true); + tip.setPrefWidth(this.getChatWidth()); + + chatBox.setPrefWidth(this.getChatWidth()); + chatBox.setAlignment(Pos.CENTER_RIGHT); + chatBox.getChildren().addAll(scrollPane, tip, chatInput); + + rightContainer.getChildren().addAll(chatButton, chatBox); + rightContainer.setAlignment(Pos.CENTER_RIGHT); + } + + public void focusChat() { + if (chatInput != null) { + chatInput.requestFocus(); + } + } + + public void toggleChat() { + if (chatButton != null) { + chatButton.fire(); + } + } + + /** + * Adds a message to the chat. + * + * @param kind The kind of message. + * @param author The author of the message. + * @param recipient The recipient of the message. + * @param message The message content. + */ + public void addMessage(String kind, String author, String recipient, String message) { + if (chatBox == null) { + return; + } + + String prefix = ""; + if (author != null) { + prefix = author; + if (recipient != null) { + prefix += " > " + recipient; + } + } + + String style = "-fx-border-radius: 10; -fx-background-radius: 10; -fx-background-color: " + + switch (kind) { + case "chat" -> "#5168C5" + (message.toLowerCase().contains("zaza") + ? "; -fx-background-color: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet)" + : ""); + case "whisper" -> "#8C93AD"; + case "yell" -> + "white; -fx-text-fill: black; -fx-border-width: 2px; -fx-border-color: #8a81bd; -fx-background-radius: 15;"; + case "user" -> "#c279e8"; + default -> "black"; + } + ";"; + + Label messageLabel = new Label(message); + messageLabel.setWrapText(true); + messageLabel.setStyle(style); + messageLabel.setFont(Fonts.get(Fonts.UI_PRIMARY)); + messageLabel.setPadding(new Insets(5, 10, 5, 10)); + + Label authorLabel = new Label(prefix); + authorLabel.setStyle("-fx-text-fill: white;"); + authorLabel.setFont(Fonts.get(Fonts.UI_SECONDARY)); + + VBox messageNode = new VBox(authorLabel, messageLabel); + messageNode.setAlignment( + author == null ? + Pos.CENTER + : (author.equals(Client.the().getOwnActor().record().orElseThrow().getNickname()) ? + Pos.CENTER_RIGHT : + Pos.CENTER_LEFT)); + messageNode.setPadding(new Insets(0, 0, 10, 0)); + + messageContainer.getChildren().add(messageNode); + } + + public void initializeSidebar() { + if (!this.view.showSidebar) { + return; + } + + List