diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..67df35c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# https://editorconfig.org +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{java,scala,groovy,kt,kts}] +indent_size = 4 + +[*.gradle] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6c84be0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text=auto eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cc4a7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,167 @@ +## Gradle: +.gradle/ +gradle-app.setting +/build/ +/android/build/ +/core/build/ +/lwjgl2/build/ +/lwjgl3/build/ +/html/build/ +/teavm/build/ +/ios/build/ +/ios-moe/build/ +/headless/build/ +/server/build/ +/shared/build/ + +## Java: +*.class +*.war +*.ear +hs_err_pid* +.attach_pid* + +## Android: +/android/libs/armeabi-v7a/ +/android/libs/arm64-v8a/ +/android/libs/x86/ +/android/libs/x86_64/ +/android/gen/ +/android/out/ +local.properties +com_crashlytics_export_strings.xml + +## Robovm: +/ios/robovm-build/ + +## iOS: +/ios/xcode/*.xcodeproj/* +!/ios/xcode/*.xcodeproj/xcshareddata +!/ios/xcode/*.xcodeproj/project.pbxproj +/ios/xcode/native/ +/ios/IOSLauncher.app +/ios/IOSLauncher.app.dSYM + +## GWT: +/html/war/ +/html/gwt-unitCache/ +.apt_generated/ +/html/war/WEB-INF/deploy/ +/html/war/WEB-INF/classes/ +.gwt/ +gwt-unitCache/ +www-test/ +.gwt-tmp/ + +## TeaVM: +# Not sure yet... + +## IntelliJ, Android Studio: +.idea/ +*.ipr +*.iws +*.iml + +## Eclipse: +.classpath +.project +.metadata/ +/android/bin/ +/core/bin/ +/lwjgl2/bin/ +/lwjgl3/bin/ +/html/bin/ +/teavm/bin/ +/ios/bin/ +/ios-moe/bin/ +/headless/bin/ +/server/bin/ +/shared/bin/ +*.tmp +*.bak +*.swp +*~.nib +.settings/ +.loadpath +.externalToolBuilders/ +*.launch + + +## NetBeans: + +/nbproject/private/ +/android/nbproject/private/ +/core/nbproject/private/ +/lwjgl2/nbproject/private/ +/lwjgl3/nbproject/private/ +/html/nbproject/private/ +/teavm/nbproject/private/ +/ios/nbproject/private/ +/ios-moe/nbproject/private/ +/headless/nbproject/private/ +/server/nbproject/private/ +/shared/nbproject/private/ + +/nbbuild/ +/android/nbbuild/ +/core/nbbuild/ +/lwjgl2/nbbuild/ +/lwjgl3/nbbuild/ +/html/nbbuild/ +/teavm/nbbuild/ +/ios/nbbuild/ +/ios-moe/nbbuild/ +/headless/nbbuild/ +/server/nbbuild/ +/shared/nbbuild/ + +/dist/ +/android/dist/ +/core/dist/ +/lwjgl2/dist/ +/lwjgl3/dist/ +/html/dist/ +/teavm/dist/ +/ios/dist/ +/ios-moe/dist/ +/headless/dist/ +/server/dist/ +/shared/dist/ + +/nbdist/ +/android/nbdist/ +/core/nbdist/ +/lwjgl2/nbdist/ +/lwjgl3/nbdist/ +/html/nbdist/ +/teavm/nbdist/ +/ios/nbdist/ +/ios-moe/nbdist/ +/headless/nbdist/ +/server/nbdist/ +/shared/nbdist/ + +nbactions.xml +nb-configuration.xml + +## OS-Specific: +.DS_Store +Thumbs.db + +## Miscellaneous: +*~ +*.*# +*#*# +/.kotlin/ +/assets/assets.txt + +## Special cases: + +## There is a resource-config.json file generated by nativeimage.gradle if you use Graal Native Image. +## Some usage may need extra resource configuration in a different file with the same name. +## You could also add that configuration to the text in nativeimage.gradle . +## You should delete or comment out the next line if you have configuration in a different resource-config.json . +**/resource-config.json + +google-services.json +service-account.json diff --git a/README.md b/README.md index 216d97d..f98ab61 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,76 @@ -# TDT4240 -Test of development branch \ No newline at end of file +# Regicide Chess + +Regicide Chess is a hybrid chess game for Android that combines classic chess rules with a regicide twist. Built with **LibGDX** and **Firebase**, it supports real-time multiplayer matches, letting players challenge friends or opponents online. + +## Project Structure + +The project follows the standard LibGDX multi-module layout: + +``` +RegicideChess/ +├── android/ # Android-specific module +│ └── .../firebase/ +│ └── AndroidFirebase.java # Main Firebase interface +├── core/ # Platform-independent game logic +│ ├── database/ # Firebase data handling +│ ├── input/ # Input processing +│ ├── model/ # Game models +│ ├── network/ # Networking utilities +│ ├── screens/ # Game screens +│ │ ├── Lobby/ # Lobby creation/joining +│ │ ├── Game/ # Main gameplay +│ │ ├── MainMenu/ # Main menu +│ │ └── Setup/ # Setup pieces +│ ├── states/ # Game state management +│ └── utils/ # Helper classes +├── assets/ # Sprites, fonts +└── ... # Gradle build files, etc. +``` + +- **android/**: Contains Android-specific code, notably `AndroidFirebase.java` which acts as the central hub for all Firebase operations. +- **core/**: All shared game logic, UI screens, input handling, and networking abstractions. This module is independent of Android and can be reused on other platforms. +- **assets/**: Images (piece sprites) + +## Compilation and Execution + +### For End Users (Play Store / APK) + +1. Download the `RegicideChess.apk` from the [Releases Page](https://git.ntnu.no/TDT4240-14/TDT4240/-/releases) +2. Copy it to your Android device and install it. You may need to enable **Install from unknown sources** in your device settings. +3. Tap the app icon to launch. + +### For Developers (Build from Source) + +#### Prerequisites +- [Android Studio](https://developer.android.com/studio) (with Android SDK and build tools) +- Java 11 or higher + +#### Steps +1. Clone the repository: + ```bash + git clone https://git.ntnu.no/TDT4240-14/TDT4240.git + ``` +2. Open the project in **Android Studio**. +3. Wait for Gradle sync to complete (it will automatically download all dependencies, including LibGDX and Firebase SDKs). +4. Build the APK: + - Go to the top menu: `Build` → `Build Bundle(s) / APK(s)` → `Build APK(s)`. +5. The debug APK will be generated at: + ``` + app/build/outputs/apk/debug/ + ``` + +#### Running on an Emulator or Device +- Connect your Android device via USB or start an emulator. +- Click the **Run** button in Android Studio. + +## Testing Multiplayer with BlueStacks + +Since multiplayer requires two devices, we recommend using [BlueStacks](https://www.bluestacks.com) (a free Android emulator for PC) to run multiple instances. + +1. Install BlueStacks and open the **Multi‑Instance Manager**. +2. Create two separate instances (e.g., `Instance 1` and `Instance 2`). +3. Launch both instances. +4. Drag the `RegicideChess.apk` file into each BlueStacks window to install the game. +5. On one instance, create a new lobby. On the other, join using the displayed **Game ID**. +6. Play a full match – this simulates two real devices and helps catch multiplayer bugs. diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..ee76cec --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..72c4450 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,130 @@ + +buildscript { + repositories { + mavenCentral() + google() + } +} +apply plugin: 'com.android.application' +apply plugin: "com.google.gms.google-services" + +android { + namespace = "com.group14.regicidechess" + compileSdk = 35 + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.setSrcDirs(['src/main/java']) + aidl.setSrcDirs(['src/main/java']) + renderscript.setSrcDirs(['src/main/java']) + res.setSrcDirs(['res']) + assets.setSrcDirs(['../assets']) + jniLibs.setSrcDirs(['libs']) + } + } + packagingOptions { + resources { + excludes += ['META-INF/robovm/ios/robovm.xml', 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES', + 'META-INF/dependencies.txt', '**/*.gwt.xml'] + pickFirsts += ['META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/license.txt', 'META-INF/LGPL2.1', + 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/notice.txt'] + } + } + defaultConfig { + applicationId 'com.group14.regicidechess' + minSdkVersion 23 + targetSdkVersion 35 + versionCode 1 + versionName "1.0" + } + compileOptions { + sourceCompatibility "8" + targetCompatibility "8" + coreLibraryDesugaringEnabled = true + } + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + // needed for AAPT2, may be needed for other tools + google() +} + +configurations { natives } + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + implementation "com.badlogicgames.gdx:gdx-backend-android:$gdxVersion" + implementation project(':core') + + implementation platform('com.google.firebase:firebase-bom:34.10.0') + + implementation 'com.google.firebase:firebase-analytics' + implementation 'com.google.firebase:firebase-database' + + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86_64" + +} + +// Called every time gradle gets executed, takes the native dependencies of +// the natives configuration, and extracts them to the proper libs/ folders +// so they get packed with the APK. +tasks.register('copyAndroidNatives') { + doFirst { + file("libs/armeabi-v7a/").mkdirs() + file("libs/arm64-v8a/").mkdirs() + file("libs/x86_64/").mkdirs() + file("libs/x86/").mkdirs() + + configurations.natives.copy().files.each { jar -> + def outputDir = null + if(jar.name.endsWith("natives-armeabi-v7a.jar")) outputDir = file("libs/armeabi-v7a") + if(jar.name.endsWith("natives-arm64-v8a.jar")) outputDir = file("libs/arm64-v8a") + if(jar.name.endsWith("natives-x86_64.jar")) outputDir = file("libs/x86_64") + if(jar.name.endsWith("natives-x86.jar")) outputDir = file("libs/x86") + if(outputDir != null) { + copy { + from zipTree(jar) + into outputDir + include "*.so" + } + } + } + } +} + +tasks.matching { it.name.contains("merge") && it.name.contains("JniLibFolders") }.configureEach { packageTask -> + packageTask.dependsOn 'copyAndroidNatives' +} + +tasks.register('run', Exec) { + def path + def localProperties = project.file("../local.properties") + if (localProperties.exists()) { + Properties properties = new Properties() + localProperties.withInputStream { instr -> + properties.load(instr) + } + def sdkDir = properties.getProperty('sdk.dir') + if (sdkDir) { + path = sdkDir + } else { + path = "$System.env.ANDROID_SDK_ROOT" + } + } else { + path = "$System.env.ANDROID_SDK_ROOT" + } + + def adb = path + "/platform-tools/adb" + commandLine "$adb", 'shell', 'am', 'start', '-n', 'com.group14.regicidechess/com.group14.regicidechess.android.AndroidLauncher' +} + +eclipse.project.name = appName + "-android" diff --git a/android/ic_launcher-web.png b/android/ic_launcher-web.png new file mode 100644 index 0000000..aa62d80 Binary files /dev/null and b/android/ic_launcher-web.png differ diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro new file mode 100644 index 0000000..1c33e94 --- /dev/null +++ b/android/proguard-rules.pro @@ -0,0 +1,52 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# https://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +-verbose + +-dontwarn android.support.** +-dontwarn com.badlogic.gdx.backends.android.AndroidFragmentApplication + +# Needed by the gdx-controllers official extension. +-keep class com.badlogic.gdx.controllers.android.AndroidControllers + +# Needed by the Box2D official extension. +-keepclassmembers class com.badlogic.gdx.physics.box2d.World { + boolean contactFilter(long, long); + boolean getUseDefaultContactFilter(); + void beginContact(long); + void endContact(long); + void preSolve(long, long); + void postSolve(long, long); + boolean reportFixture(long); + float reportRayFixture(long, float, float, float, float, float); +} + +# You will need the next three lines if you use scene2d for UI or gameplay. +# If you don't use scene2d at all, you can remove or comment out the next line: +-keep public class com.badlogic.gdx.scenes.scene2d.** { *; } +# You will need the next two lines if you use BitmapFont or any scene2d.ui text: +-keep public class com.badlogic.gdx.graphics.g2d.BitmapFont { *; } +# You will probably need this line in most cases: +-keep public class com.badlogic.gdx.graphics.Color { *; } + +# These two lines are used with mapping files; see https://developer.android.com/build/shrink-code#retracing +-keepattributes LineNumberTable,SourceFile +-renamesourcefileattribute SourceFile diff --git a/android/project.properties b/android/project.properties new file mode 100644 index 0000000..b383d92 --- /dev/null +++ b/android/project.properties @@ -0,0 +1,14 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-rules.pro + +# Project target. +target=android-21 diff --git a/android/regicidechess.png b/android/regicidechess.png new file mode 100644 index 0000000..c891e47 Binary files /dev/null and b/android/regicidechess.png differ diff --git a/android/res/drawable-anydpi-v26/ic_launcher.xml b/android/res/drawable-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6c7313a --- /dev/null +++ b/android/res/drawable-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/res/drawable-anydpi-v26/ic_launcher_foreground.xml b/android/res/drawable-anydpi-v26/ic_launcher_foreground.xml new file mode 100644 index 0000000..4439d22 --- /dev/null +++ b/android/res/drawable-anydpi-v26/ic_launcher_foreground.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/android/res/drawable-hdpi/ic_launcher.png b/android/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..5257281 Binary files /dev/null and b/android/res/drawable-hdpi/ic_launcher.png differ diff --git a/android/res/drawable-mdpi/ic_launcher.png b/android/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..a2f07d6 Binary files /dev/null and b/android/res/drawable-mdpi/ic_launcher.png differ diff --git a/android/res/drawable-xhdpi/ic_launcher.png b/android/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..611377b Binary files /dev/null and b/android/res/drawable-xhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxhdpi/ic_launcher.png b/android/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..fcb2409 Binary files /dev/null and b/android/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxxhdpi/ic_launcher.png b/android/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..8f44e34 Binary files /dev/null and b/android/res/drawable-xxxhdpi/ic_launcher.png differ diff --git a/android/res/values/color.xml b/android/res/values/color.xml new file mode 100644 index 0000000..8bd4035 --- /dev/null +++ b/android/res/values/color.xml @@ -0,0 +1,5 @@ + + + #F5A623FF + #FFFFFFFF + diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml new file mode 100644 index 0000000..cd83ec8 --- /dev/null +++ b/android/res/values/strings.xml @@ -0,0 +1,4 @@ + + + regicidechess + diff --git a/android/res/values/styles.xml b/android/res/values/styles.xml new file mode 100644 index 0000000..88fe739 --- /dev/null +++ b/android/res/values/styles.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/java/com/group14/regicidechess/android/AndroidAPI.java b/android/src/main/java/com/group14/regicidechess/android/AndroidAPI.java new file mode 100644 index 0000000..3d181be --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidAPI.java @@ -0,0 +1,20 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.FirebaseDatabase; +import com.group14.regicidechess.API; + +public class AndroidAPI implements API { + FirebaseDatabase database; + DatabaseReference lobbies; + + public AndroidAPI() { + database = FirebaseDatabase.getInstance(); + lobbies = database.getReference("lobbies"); + } + + @Override + public void createLobby() { + lobbies.push().setValue(1); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java new file mode 100644 index 0000000..0509f51 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java @@ -0,0 +1,120 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.FirebaseDatabase; +import com.group14.regicidechess.API; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.model.Move; + +public class AndroidFirebase implements FirebaseAPI, API { + + private final FirebaseUtils utils = new FirebaseUtils(); + private final DatabaseReference db = FirebaseDatabase.getInstance().getReference(); + + private final FirebaseLobbyManager lobbyManager; + private final FirebaseSetupManager setupManager; + private final FirebaseMoveManager moveManager; + private final FirebaseHeartbeatManager heartbeatManager; + private final FirebaseConnectionManager connectionManager; + private final FirebaseGameOverManager gameOverManager; + + public AndroidFirebase() { + utils.fetchServerTimeOffset(db); + lobbyManager = new FirebaseLobbyManager(db, utils); + setupManager = new FirebaseSetupManager(db, utils); + moveManager = new FirebaseMoveManager(db, utils); + heartbeatManager = new FirebaseHeartbeatManager(db, utils); + connectionManager = new FirebaseConnectionManager(db, utils); + gameOverManager = new FirebaseGameOverManager(db, utils); + } + + // Lobby + @Override public void createLobby(Lobby lobby, Callback onSuccess, Callback onError) { + lobbyManager.createLobby(lobby, onSuccess, onError); + } + @Override public void fetchLobby(String gameId, Callback onSuccess, Callback onError) { + lobbyManager.fetchLobby(gameId, onSuccess, onError); + } + @Override public void joinLobby(String gameId, Callback onSuccess, Callback onError) { + lobbyManager.joinLobby(gameId, onSuccess, onError); + } + @Override public void listenForOpponentReady(String gameId, Runnable onReady) { + lobbyManager.listenForOpponentReady(gameId, onReady); + } + @Override public void startGame(String gameId) { lobbyManager.startGame(gameId); } + @Override public void listenForGameStart(String gameId, Runnable onStart) { + lobbyManager.listenForGameStart(gameId, onStart); + } + + // Setup + @Override public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess, Callback onError) { + setupManager.confirmSetup(gameId, isWhite, board, onSuccess, onError); + } + @Override public void unconfirmSetup(String gameId, boolean isWhite, Runnable onSuccess, Callback onError) { + setupManager.unconfirmSetup(gameId, isWhite, onSuccess, onError); + } + @Override public void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard) { + setupManager.getOpponentBoard(gameId, localIsWhite, onBoard); + } + @Override public void listenForBothSetupReady(String gameId, Runnable onBothReady) { + setupManager.listenForBothSetupReady(gameId, onBothReady); + } + + // Moves + @Override public void saveMove(String gameId, Move move, Runnable onSuccess) { + moveManager.saveMove(gameId, move, onSuccess); + } + @Override public void listenForOpponentMove(String gameId, Callback onMove) { + moveManager.listenForOpponentMove(gameId, onMove); + } + + // Heartbeat & latency + @Override public void sendLatency(String gameId, boolean isWhite, long latencyMs) { + heartbeatManager.sendLatency(gameId, isWhite, latencyMs); + } + @Override public void listenForOpponentLatency(String gameId, boolean listenForWhite, Callback onLatency) { + heartbeatManager.listenForOpponentLatency(gameId, listenForWhite, onLatency); + } + @Override public void sendHeartbeat(String gameId, boolean isWhite) { + heartbeatManager.sendHeartbeat(gameId, isWhite); + } + @Override public void listenForHeartbeat(String gameId, boolean listenForWhite, long timeoutMs, Callback onHeartbeat, Runnable onTimeout) { + heartbeatManager.listenForHeartbeat(gameId, listenForWhite, timeoutMs, onHeartbeat, onTimeout); + } + + // Connection + @Override public void listenForMyConnection(Runnable onConnected, Runnable onDisconnected) { + connectionManager.listenForMyConnection(onConnected, onDisconnected); + } + @Override public void signalReconnected(String gameId, boolean isWhite) { + connectionManager.signalReconnected(gameId, isWhite); + } + @Override public void listenForOpponentReconnected(String gameId, boolean listenForWhite, Runnable onReconnected) { + connectionManager.listenForOpponentReconnected(gameId, listenForWhite, onReconnected); + } + + // Game over & disconnect hooks + @Override public void signalGameOver(String gameId, String reason) { + gameOverManager.signalGameOver(gameId, reason); + } + @Override public void registerDisconnectGameOver(String gameId, String reason) { + gameOverManager.registerDisconnectGameOver(gameId, reason); + } + @Override public void listenForGameOver(String gameId, Callback onGameOver) { + gameOverManager.listenForGameOver(gameId, onGameOver); + } + @Override public void listenForOpponentDisconnectedAt(String gameId, String opponentColor, Runnable onDisconnected) { + gameOverManager.listenForOpponentDisconnectedAt(gameId, opponentColor, onDisconnected); + } + + // Utilities + @Override public void removeAllListeners() { + utils.removeAllListeners(); + } + + // Legacy API method + @Override public void createLobby() { + db.child("lobbies").push().setValue(1); + } +} \ No newline at end of file diff --git a/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java b/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java new file mode 100644 index 0000000..6a9fcde --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java @@ -0,0 +1,24 @@ +package com.group14.regicidechess.android; + +import android.os.Bundle; + +import com.badlogic.gdx.backends.android.AndroidApplication; +import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration; +import com.group14.regicidechess.Main; +import com.group14.regicidechess.database.DatabaseManager; + +/** Launches the Android application. */ +public class AndroidLauncher extends AndroidApplication { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Initialise Firebase bridge before starting the game + AndroidFirebase firebase = new AndroidFirebase(); + DatabaseManager.getInstance().init(firebase); + + AndroidApplicationConfiguration configuration = new AndroidApplicationConfiguration(); + configuration.useImmersiveMode = true; + initialize(new Main(firebase), configuration); + } +} \ No newline at end of file diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseConnectionManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseConnectionManager.java new file mode 100644 index 0000000..4fabe92 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseConnectionManager.java @@ -0,0 +1,48 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; + +public class FirebaseConnectionManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseConnectionManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void listenForMyConnection(Runnable onConnected, Runnable onDisconnected) { + DatabaseReference connRef = db.child(".info/connected"); + ValueEventListener listener = utils.trackValue(connRef, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Boolean connected = s.getValue(Boolean.class); + if (Boolean.TRUE.equals(connected)) onConnected.run(); + else onDisconnected.run(); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public void signalReconnected(String gameId, boolean isWhite) { + String color = isWhite ? "white" : "black"; + db.child("games").child(gameId).child("disconnectedAt").child(color).onDisconnect().cancel(); + db.child("games").child(gameId).child("disconnectedAt").child(color).removeValue(); + db.child("games").child(gameId).child("reconnected").child(color).setValue(true); + } + + public void listenForOpponentReconnected(String gameId, boolean listenForWhite, Runnable onReconnected) { + String color = listenForWhite ? "white" : "black"; + DatabaseReference ref = db.child("games").child(gameId).child("reconnected").child(color); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Boolean val = s.getValue(Boolean.class); + if (Boolean.TRUE.equals(val)) onReconnected.run(); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseGameOverManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseGameOverManager.java new file mode 100644 index 0000000..32107b2 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseGameOverManager.java @@ -0,0 +1,69 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.MutableData; +import com.google.firebase.database.ServerValue; +import com.google.firebase.database.Transaction; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; + +public class FirebaseGameOverManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseGameOverManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void signalGameOver(String gameId, String reason) { + DatabaseReference ref = db.child("games").child(gameId).child("gameOver"); + ref.onDisconnect().cancel(); + ref.runTransaction(new Transaction.Handler() { + @Override public Transaction.Result doTransaction(MutableData currentData) { + if (currentData.getValue() != null) return Transaction.abort(); + currentData.setValue(reason); + return Transaction.success(currentData); + } + @Override public void onComplete(DatabaseError error, boolean committed, DataSnapshot snapshot) {} + }); + } + + public void registerDisconnectGameOver(String gameId, String reason) { + String color = reason.startsWith("disconnect:") ? reason.substring("disconnect:".length()) : reason; + DatabaseReference ref = db.child("games").child(gameId).child("disconnectedAt").child(color); + ref.onDisconnect().setValue(ServerValue.TIMESTAMP); + } + + public void listenForGameOver(String gameId, FirebaseAPI.Callback onGameOver) { + DatabaseReference ref = db.child("games").child(gameId).child("gameOver"); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + String reason = s.getValue(String.class); + if (reason != null) { + ref.removeEventListener(this); + onGameOver.call(reason); + } + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public void listenForOpponentDisconnectedAt(String gameId, String opponentColor, Runnable onDisconnected) { + DatabaseReference ref = db.child("games").child(gameId).child("disconnectedAt").child(opponentColor); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + if (s.getValue() != null) { + ref.removeEventListener(this); + utils.untrackValue(this); + onDisconnected.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + utils.trackValue(ref, listener); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseHeartbeatManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseHeartbeatManager.java new file mode 100644 index 0000000..ae8b786 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseHeartbeatManager.java @@ -0,0 +1,97 @@ +package com.group14.regicidechess.android; + +import android.os.Handler; +import android.os.Looper; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ServerValue; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class FirebaseHeartbeatManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseHeartbeatManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void sendLatency(String gameId, boolean isWhite, long latencyMs) { + String player = isWhite ? "white" : "black"; + db.child("games").child(gameId).child("latency").child(player).setValue(latencyMs); + } + + public void listenForOpponentLatency(String gameId, boolean listenForWhite, FirebaseAPI.Callback onLatency) { + String player = listenForWhite ? "white" : "black"; + DatabaseReference ref = db.child("games").child(gameId).child("latency").child(player); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Long latency = s.getValue(Long.class); + if (latency != null) onLatency.call(latency); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public void sendHeartbeat(String gameId, boolean isWhite) { + String playerKey = isWhite ? "white" : "black"; + long correctedSendTime = utils.serverNow(); + Map data = new HashMap<>(); + data.put("timestamp", ServerValue.TIMESTAMP); + data.put("serverCorrectedSendTime", correctedSendTime); + data.put("sender", playerKey); + db.child("games").child(gameId).child("heartbeat").child(playerKey).setValue(data); + } + + public void listenForHeartbeat(String gameId, boolean listenForWhite, + long timeoutMs, FirebaseAPI.Callback onHeartbeat, Runnable onTimeout) { + String player = listenForWhite ? "white" : "black"; + Handler handler = new Handler(Looper.getMainLooper()); + AtomicBoolean currentlyTimedOut = new AtomicBoolean(false); + final long[] lastHeartbeatTime = {utils.serverNow()}; + + Runnable timeoutRunnable = new Runnable() { + @Override public void run() { + long now = utils.serverNow(); + long timeSinceLast = now - lastHeartbeatTime[0]; + if (timeSinceLast >= timeoutMs) { + if (!currentlyTimedOut.getAndSet(true)) onTimeout.run(); + } else { + currentlyTimedOut.set(false); + } + handler.postDelayed(this, timeoutMs / 3); + } + }; + handler.postDelayed(timeoutRunnable, timeoutMs); + + DatabaseReference ref = db.child("games").child(gameId).child("heartbeat").child(player); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Object value = s.getValue(); + if (value == null) return; + lastHeartbeatTime[0] = utils.serverNow(); + currentlyTimedOut.set(false); + long rtt = 0; + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) value; + Object sendTime = map.get("serverCorrectedSendTime"); + if (sendTime instanceof Long) { + rtt = utils.serverNow() - (Long) sendTime; + if (rtt < 0) rtt = 0; + } + } + onHeartbeat.call(rtt); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseLobbyManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseLobbyManager.java new file mode 100644 index 0000000..c472c4d --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseLobbyManager.java @@ -0,0 +1,92 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Lobby; + +import java.util.HashMap; +import java.util.Map; + +public class FirebaseLobbyManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseLobbyManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void createLobby(Lobby lobby, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + String gameId = lobby.getGameId(); + Map data = new HashMap<>(); + data.put("boardSize", lobby.getBoardSize()); + data.put("budget", lobby.getBudget()); + data.put("status", "waiting"); + + db.child("lobbies").child(gameId).setValue(data) + .addOnSuccessListener(v -> onSuccess.call(gameId)) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void fetchLobby(String gameId, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + db.child("lobbies").child(gameId).get() + .addOnSuccessListener(snapshot -> { + if (!snapshot.exists()) { onError.call("Lobby not found"); return; } + int boardSize = utils.getInt(snapshot, "boardSize"); + int budget = utils.getInt(snapshot, "budget"); + onSuccess.call(new Lobby(gameId, boardSize, budget, + System.currentTimeMillis() + 30 * 60 * 1000L)); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void joinLobby(String gameId, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + db.child("lobbies").child(gameId).get() + .addOnSuccessListener(snapshot -> { + if (!snapshot.exists()) { onError.call("Lobby not found"); return; } + int boardSize = utils.getInt(snapshot, "boardSize"); + int budget = utils.getInt(snapshot, "budget"); + db.child("lobbies").child(gameId).child("status").setValue("joined"); + onSuccess.call(new Lobby(gameId, boardSize, budget, + System.currentTimeMillis() + 30 * 60 * 1000L)); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void listenForOpponentReady(String gameId, Runnable onReady) { + DatabaseReference ref = db.child("lobbies").child(gameId).child("status"); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + if ("joined".equals(s.getValue(String.class))) { + ref.removeEventListener(this); + onReady.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + ref.addValueEventListener(listener); + // cleanup will be handled by utils if needed, but we don't track here for simplicity + } + + public void startGame(String gameId) { + db.child("lobbies").child(gameId).child("status").setValue("started"); + } + + public void listenForGameStart(String gameId, Runnable onStart) { + DatabaseReference ref = db.child("lobbies").child(gameId).child("status"); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + if ("started".equals(s.getValue(String.class))) { + ref.removeEventListener(this); + onStart.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + ref.addValueEventListener(listener); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseMoveManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseMoveManager.java new file mode 100644 index 0000000..7cbaee0 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseMoveManager.java @@ -0,0 +1,73 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.ChildEventListener; +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ServerValue; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Move; + +import java.util.HashMap; +import java.util.Map; + +public class FirebaseMoveManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseMoveManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void saveMove(String gameId, Move move, Runnable onSuccess) { + Map data = new HashMap<>(); + data.put("fromCol", (int) move.getFrom().x); + data.put("fromRow", (int) move.getFrom().y); + data.put("toCol", (int) move.getTo().x); + data.put("toRow", (int) move.getTo().y); + data.put("player", move.getPlayer().isWhite() ? "white" : "black"); + data.put("timestamp", ServerValue.TIMESTAMP); + if (move.getPromotion() != null) { + data.put("promotion", move.getPromotion()); + } + + db.child("games").child(gameId).child("moves").push() + .setValue(data) + .addOnSuccessListener(v -> onSuccess.run()); + } + + public void listenForOpponentMove(String gameId, FirebaseAPI.Callback onMove) { + long startTime = System.currentTimeMillis(); + DatabaseReference movesRef = db.child("games").child(gameId).child("moves"); + + movesRef.orderByChild("timestamp").startAt(startTime) + .addChildEventListener(utils.trackChild(movesRef, new ChildEventListener() { + @Override public void onChildAdded(DataSnapshot s, String prev) { + int fromCol = utils.getInt(s, "fromCol"); + int fromRow = utils.getInt(s, "fromRow"); + int toCol = utils.getInt(s, "toCol"); + int toRow = utils.getInt(s, "toRow"); + String mover = s.child("player").getValue(String.class); + int isWhite = "white".equals(mover) ? 1 : 0; + + String promo = s.child("promotion").getValue(String.class); + int promoCode = 0; + if (promo != null) { + switch (promo) { + case "Queen": promoCode = 2; break; + case "Rook": promoCode = 3; break; + case "Bishop": promoCode = 4; break; + case "Knight": promoCode = 5; break; + } + } + onMove.call(new int[]{fromCol, fromRow, toCol, toRow, isWhite, promoCode}); + } + @Override public void onChildChanged(DataSnapshot s, String p) {} + @Override public void onChildRemoved(DataSnapshot s) {} + @Override public void onChildMoved(DataSnapshot s, String p) {} + @Override public void onCancelled(DatabaseError e) {} + })); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java new file mode 100644 index 0000000..eb0aaf3 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java @@ -0,0 +1,120 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FirebaseSetupManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseSetupManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void confirmSetup(String gameId, boolean isWhite, int[][] board, + Runnable onSuccess, FirebaseAPI.Callback onError) { + String player = isWhite ? "white" : "black"; + DatabaseReference gameRef = db.child("games").child(gameId); + + List> pieces = new ArrayList<>(); + for (int col = 0; col < board.length; col++) { + for (int row = 0; row < board[col].length; row++) { + if (board[col][row] != 0) { + Map entry = new HashMap<>(); + entry.put("col", col); + entry.put("row", row); + entry.put("piece", board[col][row]); + pieces.add(entry); + } + } + } + + gameRef.child("boards").child(player).setValue(pieces) + .addOnSuccessListener(v1 -> + gameRef.child("setup").child(player).setValue("ready") + .addOnSuccessListener(v2 -> + gameRef.child("setup").addListenerForSingleValueEvent(new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + boolean whiteReady = "ready".equals(s.child("white").getValue(String.class)); + boolean blackReady = "ready".equals(s.child("black").getValue(String.class)); + if (whiteReady && blackReady) { + gameRef.child("bothReady").setValue(true); + } + onSuccess.run(); + } + @Override public void onCancelled(DatabaseError e) { onError.call(e.getMessage()); } + }) + ) + .addOnFailureListener(e -> onError.call(e.getMessage())) + ) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void unconfirmSetup(String gameId, boolean isWhite, + Runnable onSuccess, FirebaseAPI.Callback onError) { + String player = isWhite ? "white" : "black"; + DatabaseReference gameRef = db.child("games").child(gameId); + gameRef.child("setup").child(player).removeValue() + .addOnSuccessListener(v -> { + gameRef.child("bothReady").removeValue() + .addOnSuccessListener(v2 -> onSuccess.run()) + .addOnFailureListener(e -> onError.call(e.getMessage())); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void getOpponentBoard(String gameId, boolean localIsWhite, FirebaseAPI.Callback onBoard) { + String opponentKey = localIsWhite ? "black" : "white"; + db.child("games").child(gameId).child("boards").child(opponentKey).get() + .addOnSuccessListener(snapshot -> { + if (!snapshot.exists()) { onBoard.call(new int[0][0]); return; } + int maxCol = 0, maxRow = 0; + for (DataSnapshot entry : snapshot.getChildren()) { + int c = utils.getInt(entry, "col"), r = utils.getInt(entry, "row"); + if (c > maxCol) maxCol = c; + if (r > maxRow) maxRow = r; + } + int size = Math.max(maxCol, maxRow) + 1; + int[][] b = new int[size][size]; + for (DataSnapshot entry : snapshot.getChildren()) { + b[utils.getInt(entry, "col")][utils.getInt(entry, "row")] = utils.getInt(entry, "piece"); + } + onBoard.call(b); + }) + .addOnFailureListener(e -> onBoard.call(new int[0][0])); + } + + private ValueEventListener bothReadyListener; + + public void listenForBothSetupReady(String gameId, Runnable onBothReady) { + DatabaseReference ref = db.child("games").child(gameId).child("bothReady"); + + // Remove existing listener if it exists to prevent duplicates + if (bothReadyListener != null) { + ref.removeEventListener(bothReadyListener); + } + + bothReadyListener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Boolean bothReady = s.getValue(Boolean.class); + if (bothReady != null && bothReady) { + ref.removeEventListener(this); + bothReadyListener = null; // Clear reference + onBothReady.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + ref.addValueEventListener(bothReadyListener); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseUtils.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseUtils.java new file mode 100644 index 0000000..6fedc72 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseUtils.java @@ -0,0 +1,79 @@ +package com.group14.regicidechess.android; + +import android.util.Log; + +import com.google.firebase.database.ChildEventListener; +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FirebaseUtils { + + private final List cleanupActions = new ArrayList<>(); + private final Map valueCleanups = new HashMap<>(); + private final Map childCleanups = new HashMap<>(); + + private volatile long serverTimeOffset = 0L; + + public void fetchServerTimeOffset(DatabaseReference db) { + db.child(".info/serverTimeOffset").addListenerForSingleValueEvent(new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Object val = s.getValue(); + if (val instanceof Long) serverTimeOffset = (Long) val; + else if (val instanceof Double) serverTimeOffset = ((Double) val).longValue(); + Log.d("FirebaseUtils", "serverTimeOffset=" + serverTimeOffset); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public long serverNow() { + return System.currentTimeMillis() + serverTimeOffset; + } + + public ValueEventListener trackValue(DatabaseReference ref, ValueEventListener listener) { + Runnable cleanup = () -> ref.removeEventListener(listener); + cleanupActions.add(cleanup); + valueCleanups.put(listener, cleanup); + ref.addValueEventListener(listener); + return listener; + } + + public void untrackValue(ValueEventListener listener) { + Runnable cleanup = valueCleanups.remove(listener); + if (cleanup != null) cleanupActions.remove(cleanup); + } + + public ChildEventListener trackChild(DatabaseReference ref, ChildEventListener listener) { + Runnable cleanup = () -> ref.removeEventListener(listener); + cleanupActions.add(cleanup); + childCleanups.put(listener, cleanup); + ref.addChildEventListener(listener); + return listener; + } + + public void untrackChild(ChildEventListener listener) { + Runnable cleanup = childCleanups.remove(listener); + if (cleanup != null) cleanupActions.remove(cleanup); + } + + public void removeAllListeners() { + for (Runnable cleanup : cleanupActions) cleanup.run(); + cleanupActions.clear(); + valueCleanups.clear(); + childCleanups.clear(); + } + + public int getInt(DataSnapshot snapshot, String key) { + Object val = snapshot.child(key).getValue(); + if (val instanceof Long) return ((Long) val).intValue(); + if (val instanceof Integer) return (Integer) val; + return 0; + } +} diff --git a/assets/libgdx.png b/assets/libgdx.png new file mode 100644 index 0000000..ac0d528 Binary files /dev/null and b/assets/libgdx.png differ diff --git a/assets/pieces/black_bishop.png b/assets/pieces/black_bishop.png new file mode 100644 index 0000000..eff5594 Binary files /dev/null and b/assets/pieces/black_bishop.png differ diff --git a/assets/pieces/black_king.png b/assets/pieces/black_king.png new file mode 100644 index 0000000..95594dc Binary files /dev/null and b/assets/pieces/black_king.png differ diff --git a/assets/pieces/black_knight.png b/assets/pieces/black_knight.png new file mode 100644 index 0000000..d971bc4 Binary files /dev/null and b/assets/pieces/black_knight.png differ diff --git a/assets/pieces/black_pawn.png b/assets/pieces/black_pawn.png new file mode 100644 index 0000000..6484532 Binary files /dev/null and b/assets/pieces/black_pawn.png differ diff --git a/assets/pieces/black_queen.png b/assets/pieces/black_queen.png new file mode 100644 index 0000000..c7c4590 Binary files /dev/null and b/assets/pieces/black_queen.png differ diff --git a/assets/pieces/black_rook.png b/assets/pieces/black_rook.png new file mode 100644 index 0000000..47bdcbb Binary files /dev/null and b/assets/pieces/black_rook.png differ diff --git a/assets/pieces/white_bishop.png b/assets/pieces/white_bishop.png new file mode 100644 index 0000000..daeb84f Binary files /dev/null and b/assets/pieces/white_bishop.png differ diff --git a/assets/pieces/white_king.png b/assets/pieces/white_king.png new file mode 100644 index 0000000..2ef8091 Binary files /dev/null and b/assets/pieces/white_king.png differ diff --git a/assets/pieces/white_knight.png b/assets/pieces/white_knight.png new file mode 100644 index 0000000..16a0e09 Binary files /dev/null and b/assets/pieces/white_knight.png differ diff --git a/assets/pieces/white_pawn.png b/assets/pieces/white_pawn.png new file mode 100644 index 0000000..ceb299c Binary files /dev/null and b/assets/pieces/white_pawn.png differ diff --git a/assets/pieces/white_queen.png b/assets/pieces/white_queen.png new file mode 100644 index 0000000..eab6135 Binary files /dev/null and b/assets/pieces/white_queen.png differ diff --git a/assets/pieces/white_rook.png b/assets/pieces/white_rook.png new file mode 100644 index 0000000..f9fae31 Binary files /dev/null and b/assets/pieces/white_rook.png differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..37ebf99 --- /dev/null +++ b/build.gradle @@ -0,0 +1,69 @@ +buildscript { + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + google() + maven { url = 'https://central.sonatype.com/repository/maven-snapshots/' } + } + dependencies { + classpath "com.android.tools.build:gradle:8.9.3" + classpath 'com.google.gms:google-services:4.4.4' + } +} + +allprojects { + apply plugin: 'eclipse' + apply plugin: 'idea' + + // This allows you to "Build and run using IntelliJ IDEA", an option in IDEA's Settings. + idea { + module { + outputDir = file('build/classes/java/main') + testOutputDir = file('build/classes/java/test') + } + } +} + +configure(subprojects - project(':android')) { + apply plugin: 'java-library' + java.sourceCompatibility = 8 + + // From https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/ + // The article can be helpful when using assets.txt in your project. + tasks.register('generateAssetList') { + inputs.dir("${project.rootDir}/assets/") + // projectFolder/assets + File assetsFolder = new File("${project.rootDir}/assets/") + // projectFolder/assets/assets.txt + File assetsFile = new File(assetsFolder, "assets.txt") + // delete that file in case we've already created it + assetsFile.delete() + + // iterate through all files inside that folder + // convert it to a relative path + // and append it to the file assets.txt + fileTree(assetsFolder).collect { assetsFolder.relativePath(it) }.sort().each { + assetsFile.append(it + "\n") + } + } + processResources.dependsOn 'generateAssetList' + + compileJava { + options.incremental = true + } +} + +subprojects { + version = "$projectVersion" + ext.appName = 'regicidechess' + repositories { + mavenCentral() + // You may want to remove the following line if you have errors downloading dependencies. + mavenLocal() + maven { url = 'https://central.sonatype.com/repository/maven-snapshots/' } + maven { url = 'https://jitpack.io' } + } +} + +eclipse.project.name = 'regicidechess' + '-parent' diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..2106df5 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,10 @@ +[compileJava, compileTestJava]*.options*.encoding = 'UTF-8' +eclipse.project.name = appName + '-core' + +dependencies { + api "com.badlogicgames.gdx:gdx:$gdxVersion" + + if(enableGraalNative == 'true') { + implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion" + } +} diff --git a/core/src/main/java/com/group14/regicidechess/API.java b/core/src/main/java/com/group14/regicidechess/API.java new file mode 100644 index 0000000..64b4926 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/API.java @@ -0,0 +1,5 @@ +package com.group14.regicidechess; + +public interface API { + public void createLobby(); +} diff --git a/core/src/main/java/com/group14/regicidechess/Main.java b/core/src/main/java/com/group14/regicidechess/Main.java new file mode 100644 index 0000000..7482b99 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/Main.java @@ -0,0 +1,28 @@ +package com.group14.regicidechess; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; +import com.group14.regicidechess.utils.ResourceManager; + +public class Main extends Game { + private SpriteBatch batch; + + API api; + + public Main(API api) { + this.api = api; + } + + @Override + public void create() { + batch = new SpriteBatch(); + setScreen(new MainMenuScreen(this, batch)); + } + + @Override + public void dispose() { + batch.dispose(); + ResourceManager.getInstance().dispose(); + } +} diff --git a/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java b/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java new file mode 100644 index 0000000..9a84a45 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java @@ -0,0 +1,33 @@ +package com.group14.regicidechess.database; + +public class DatabaseManager { + + private static DatabaseManager instance; + private FirebaseAPI api; + + private DatabaseManager() {} + + public static DatabaseManager getInstance() { + if (instance == null) instance = new DatabaseManager(); + return instance; + } + + public void init(FirebaseAPI api) { + this.api = api; + } + + /** + * Returns the FirebaseAPI implementation. + * Throws a clear IllegalStateException if init() was never called, + * so the cause is obvious instead of a confusing NullPointerException. + */ + public FirebaseAPI getApi() { + if (api == null) { + throw new IllegalStateException( + "DatabaseManager not initialised! " + + "Call DatabaseManager.getInstance().init(firebase) " + + "in your launcher before starting the game."); + } + return api; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java new file mode 100644 index 0000000..7247ead --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java @@ -0,0 +1,158 @@ +// FirebaseAPI.java +package com.group14.regicidechess.database; + +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.model.Move; + +public interface FirebaseAPI { + + // ── Lobby ───────────────────────────────────────────────────────────────── + + void createLobby(Lobby lobby, Callback onSuccess, Callback onError); + void joinLobby(String gameId, Callback onSuccess, Callback onError); + + /** Reads lobby settings without any side effects. Used by MainMenuScreen to validate. */ + void fetchLobby(String gameId, Callback onSuccess, Callback onError); + + /** + * Fires once when lobbies/{gameId}/status == "joined". + * HOST calls this to know a second player has entered the lobby. + */ + void listenForOpponentReady(String gameId, Runnable onReady); + + /** + * Sets lobbies/{gameId}/status = "started". + * HOST calls this when pressing "Start Game" so the joiner navigates to SetupScreen. + */ + void startGame(String gameId); + + /** + * Fires once when lobbies/{gameId}/status == "started". + * JOINER calls this to know the host has pressed "Start Game". + */ + void listenForGameStart(String gameId, Runnable onStart); + + // ── Setup ───────────────────────────────────────────────────────────────── + + /** + * Saves this player's board layout and marks them as setup-ready. + * Sets games/{gameId}/bothReady = true once BOTH players have called this. + */ + void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess, Callback onError); + + /** + * Unconfirms the setup, marking this player as not ready. + */ + void unconfirmSetup(String gameId, boolean isWhite, Runnable onSuccess, Callback onError); + + /** Fetches the opponent's stored board layout. */ + void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard); + + /** + * Fires once when games/{gameId}/bothReady == true. + * BOTH players call this in SetupScreen after confirming their boards. + * NOTE: Completely separate from listenForGameStart() which is lobby-phase only. + */ + void listenForBothSetupReady(String gameId, Runnable onBothReady); + + // ── In-match ────────────────────────────────────────────────────────────── + + void saveMove(String gameId, Move move, Runnable onSuccess); + + /** + * Fires for every move added to games/{gameId}/moves. + * Returns int[]{fromCol, fromRow, toCol, toRow, isWhite(1=white/0=black)}. + * Caller filters out own moves by comparing isWhite to localPlayer.isWhite(). + */ + void listenForOpponentMove(String gameId, Callback onMove); + + void sendHeartbeat(String gameId, boolean isWhite); + + /** + * Listens for the opponent's heartbeat. + * @param onHeartbeat called with the latency (delay) in ms when a heartbeat arrives. + * @param onTimeout called if no heartbeat is received within timeoutMs. + */ + void listenForHeartbeat(String gameId, boolean listenForWhite, long timeoutMs, + Callback onHeartbeat, Runnable onTimeout); + + /** + * Listens to Firebase's own .info/connected node. + * onConnected fires with true when this device has a live Firebase connection, + * false when it goes offline. Fires immediately with the current state. + */ + void listenForMyConnection(Runnable onConnected, Runnable onDisconnected); + + /** Publishes this player's measured latency so the opponent can display it. */ + void sendLatency(String gameId, boolean isWhite, long latencyMs); + + /** Listens for the opponent's published latency. listenForWhite = true → watch white's value. */ + void listenForOpponentLatency(String gameId, boolean listenForWhite, Callback onLatency); + + /** e.g. "forfeit:white", "forfeit:black" */ + void signalGameOver(String gameId, String reason); + + /** + * Registers a server-side Firebase onDisconnect hook so that if this + * client drops unexpectedly, the server writes a timestamp to + * games/{gameId}/disconnectedAt/{color}. + * + * IMPORTANT: This does NOT write to gameOver directly. The still-connected + * opponent listens to disconnectedAt via listenForOpponentDisconnectedAt and + * starts a RECONNECT_GRACE_MS countdown. Only after that grace period expires + * (and the opponent hasn't called signalReconnected) does the connected player + * write to gameOver. This guarantees both sides have the full grace window + * before any win/loss screen is shown. + * + * The hook is cancelled by signalReconnected() when the player comes back, + * and by signalGameOver() for clean match endings. + * + * Typical call: api.registerDisconnectGameOver(gameId, "disconnect:white") + */ + void registerDisconnectGameOver(String gameId, String reason); + + void listenForGameOver(String gameId, Callback onGameOver); + + /** + * Fires once when games/{gameId}/disconnectedAt/{opponentColor} becomes + * non-null. This is written by the server-side onDisconnect hook registered + * in registerDisconnectGameOver() and is the authoritative signal that the + * opponent's Firebase connection dropped. + * + * The connected player uses this to start the RECONNECT_GRACE_MS countdown. + * Unlike heartbeat timeout (which can be noisy on a slow network), this + * signal comes from Firebase's own infrastructure and is reliable. + * + * @param gameId the active game ID + * @param opponentColor "white" or "black" + * @param onDisconnected called once when the timestamp appears + */ + void listenForOpponentDisconnectedAt(String gameId, String opponentColor, + Runnable onDisconnected); + + /** + * Writes games/{gameId}/reconnected/{color} = true and clears + * games/{gameId}/disconnectedAt/{color} so the still-connected opponent + * knows this player came back within the grace window and cancels their + * auto-forfeit timer. + * + * Also cancels the server-side onDisconnect hook so it never fires again + * for this session. + */ + void signalReconnected(String gameId, boolean isWhite); + + /** + * Fires once if games/{gameId}/reconnected/{opponentColor} becomes true + * within the grace window. Used by the connected player to cancel their + * auto-forfeit timer when the opponent comes back online. + */ + void listenForOpponentReconnected(String gameId, boolean listenForWhite, Runnable onReconnected); + + void removeAllListeners(); + + // ───────────────────────────────────────────────────────────────────────── + + interface Callback { + void call(T value); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/input/ScreenInputHandler.java b/core/src/main/java/com/group14/regicidechess/input/ScreenInputHandler.java new file mode 100644 index 0000000..04496ed --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/input/ScreenInputHandler.java @@ -0,0 +1,125 @@ +package com.group14.regicidechess.input; + +import com.badlogic.gdx.InputProcessor; + +/** + * ScreenInputHandler — Observer-based bridge between LibGDX raw input events and + * the application's screen-level logic. + * + * Placement: core/src/main/java/com/regicidechess/input/ScreenInputHandler.java + * + * Pattern (from architecture doc §6.4): + * GUI elements register as ScreenInputObservers. When a player action occurs + * (touch, key press) all registered observers are notified and update accordingly. + * + * Usage: + * ScreenInputHandler handler = new ScreenInputHandler(); + * handler.addObserver(myScreen); + * Gdx.input.setInputProcessor(handler); + */ +public class ScreenInputHandler implements InputProcessor { + + // ------------------------------------------------------------------------- + // Observer interface + // ------------------------------------------------------------------------- + + /** + * Any screen or component that wants to receive filtered input events + * should implement this interface and register itself with the handler. + */ + public interface ScreenInputObserver { + + /** + * Called when the player taps / clicks at screen coordinates (screenX, screenY). + * Coordinates are in LibGDX screen space (origin at top-left). + */ + void onTap(int screenX, int screenY, int pointer, int button); + + /** + * Called when the player drags (touch-move or mouse-drag). + */ + void onDrag(int screenX, int screenY, int pointer); + + /** + * Called when a touch / mouse button is released. + */ + void onRelease(int screenX, int screenY, int pointer, int button); + + /** + * Called for every key press. Screens that don't care about keys + * can provide an empty body. + */ + void onKeyDown(int keycode); + } + + // ------------------------------------------------------------------------- + // Observer registry + // ------------------------------------------------------------------------- + + private final java.util.List observers = new java.util.ArrayList<>(); + + public void addObserver(ScreenInputObserver observer) { + if (!observers.contains(observer)) { + observers.add(observer); + } + } + + public void removeObserver(ScreenInputObserver observer) { + observers.remove(observer); + } + + public void clearObservers() { + observers.clear(); + } + + // ------------------------------------------------------------------------- + // InputProcessor implementation + // ------------------------------------------------------------------------- + + @Override + public boolean touchDown(int screenX, int screenY, int pointer, int button) { + // touchDown is the start of a gesture; we fire onTap immediately for + // simple tap-based UIs. Screens that need drag-distinction can use + // onDrag / onRelease to differentiate. + for (ScreenInputObserver obs : observers) { + obs.onTap(screenX, screenY, pointer, button); + } + return true; + } + + @Override + public boolean touchUp(int screenX, int screenY, int pointer, int button) { + for (ScreenInputObserver obs : observers) { + obs.onRelease(screenX, screenY, pointer, button); + } + return true; + } + + @Override + public boolean touchDragged(int screenX, int screenY, int pointer) { + for (ScreenInputObserver obs : observers) { + obs.onDrag(screenX, screenY, pointer); + } + return true; + } + + @Override + public boolean keyDown(int keycode) { + for (ScreenInputObserver obs : observers) { + obs.onKeyDown(keycode); + } + return false; + } + + // ---- unused InputProcessor methods ---------------------------------------- + + @Override public boolean keyUp(int keycode) { return false; } + @Override public boolean keyTyped(char character) { return false; } + @Override public boolean mouseMoved(int screenX, int screenY) { return false; } + @Override public boolean scrolled(float amountX, float amountY) { return false; } + + // touchCancelled added in LibGDX 1.11+ + public boolean touchCancelled(int screenX, int screenY, int pointer, int button) { + return false; + } +} diff --git a/core/src/main/java/com/group14/regicidechess/model/Board.java b/core/src/main/java/com/group14/regicidechess/model/Board.java new file mode 100644 index 0000000..05aa754 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/Board.java @@ -0,0 +1,177 @@ +package com.group14.regicidechess.model; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.pieces.*; + +import java.util.ArrayList; +import java.util.List; + +public class Board { + + private final int size; + private final ChessPiece[][] pieces; + private Vector2 enPassantTarget; + + public Board(int size) { + this.size = size; + this.pieces = new ChessPiece[size][size]; + this.enPassantTarget = null; + } + + // ------------------------------------------------------------------------- + // Placement + // ------------------------------------------------------------------------- + + public void placePiece(ChessPiece piece, Vector2 pos) { + placePiece(piece, (int) pos.x, (int) pos.y); + } + + public void placePiece(ChessPiece piece, int col, int row) { + if (!inBounds(col, row)) return; + pieces[col][row] = piece; + if (piece != null) { + piece.setPosition(col, row); + piece.setBoard(this); + } + } + + public ChessPiece removePiece(Vector2 pos) { + return removePiece((int) pos.x, (int) pos.y); + } + + public ChessPiece removePiece(int col, int row) { + if (!inBounds(col, row)) return null; + ChessPiece piece = pieces[col][row]; + pieces[col][row] = null; + return piece; + } + + public ChessPiece movePiece(Vector2 from, Vector2 to) { + return movePiece((int) from.x, (int) from.y, (int) to.x, (int) to.y); + } + + public ChessPiece movePiece(int fromCol, int fromRow, int toCol, int toRow) { + ChessPiece moving = getPieceAt(fromCol, fromRow); + if (moving == null) return null; + + // Castling + if (moving instanceof King && Math.abs(toCol - fromCol) == 2 && fromRow == toRow) { + return handleCastling((King) moving, fromCol, fromRow, toCol, toRow); + } + + // En passant capture + if (moving instanceof Pawn && enPassantTarget != null + && toCol == (int) enPassantTarget.x && toRow == (int) enPassantTarget.y) { + int capturedRow = (moving.getOwner().isWhite() ? toRow - 1 : toRow + 1); + ChessPiece capturedPawn = removePiece(toCol, capturedRow); + placePiece(removePiece(fromCol, fromRow), toCol, toRow); + ((Pawn) moving).markMoved(); + enPassantTarget = null; + return capturedPawn; + } + + // Normal move + ChessPiece captured = removePiece(toCol, toRow); + placePiece(removePiece(fromCol, fromRow), toCol, toRow); + + moving.setHasMoved(true); + if (moving instanceof Pawn) ((Pawn) moving).markMoved(); + + enPassantTarget = null; + if (moving instanceof Pawn && Math.abs(toRow - fromRow) == 2) { + int midRow = (fromRow + toRow) / 2; + enPassantTarget = new Vector2(toCol, midRow); + } + + return captured; + } + + private ChessPiece handleCastling(King king, int fromCol, int fromRow, int toCol, int toRow) { + int direction = (toCol > fromCol) ? 1 : -1; + int rookCol = -1; + for (int c = fromCol + direction; c >= 0 && c < size; c += direction) { + ChessPiece piece = getPieceAt(c, fromRow); + if (piece instanceof Rook && piece.getOwner() == king.getOwner()) { + rookCol = c; + break; + } + } + if (rookCol == -1) return null; + + ChessPiece rook = removePiece(rookCol, fromRow); + removePiece(fromCol, fromRow); + + placePiece(king, toCol, toRow); + int newRookCol = toCol - direction; + placePiece(rook, newRookCol, toRow); + + king.setHasMoved(true); + rook.setHasMoved(true); + return null; + } + + // ------------------------------------------------------------------------- + // Attack detection – uses attacksSquare, no recursion + // ------------------------------------------------------------------------- + + public boolean isSquareAttacked(int col, int row, Player owner) { + for (int c = 0; c < size; c++) { + for (int r = 0; r < size; r++) { + ChessPiece piece = pieces[c][r]; + if (piece != null && piece.getOwner() != owner) { + if (piece.attacksSquare(col, row)) return true; + } + } + } + return false; + } + + // ------------------------------------------------------------------------- + // Queries + // ------------------------------------------------------------------------- + + public ChessPiece getPieceAt(Vector2 pos) { + return getPieceAt((int) pos.x, (int) pos.y); + } + + public ChessPiece getPieceAt(int col, int row) { + if (!inBounds(col, row)) return null; + return pieces[col][row]; + } + + public List getPieces() { + List result = new ArrayList<>(); + for (int c = 0; c < size; c++) + for (int r = 0; r < size; r++) + if (pieces[c][r] != null) result.add(pieces[c][r]); + return result; + } + + public List getPieces(Player player) { + List result = new ArrayList<>(); + for (ChessPiece p : getPieces()) + if (p.getOwner() == player) result.add(p); + return result; + } + + public boolean inBounds(int col, int row) { + return col >= 0 && col < size && row >= 0 && row < size; + } + + public int getSize() { return size; } + + public void clear() { + for (int c = 0; c < size; c++) + for (int r = 0; r < size; r++) + pieces[c][r] = null; + enPassantTarget = null; + } + + public Vector2 getEnPassantTarget() { + return enPassantTarget == null ? null : enPassantTarget.cpy(); + } + + public void clearEnPassantTarget() { + enPassantTarget = null; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/Lobby.java b/core/src/main/java/com/group14/regicidechess/model/Lobby.java new file mode 100644 index 0000000..4496dc6 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/Lobby.java @@ -0,0 +1,66 @@ +package com.group14.regicidechess.model; + +import java.util.UUID; + +/** + * Lobby — Holds game configuration for one match session. + * + * Placement: core/src/main/java/com/group14/regicidechess/model/Lobby.java + */ +public class Lobby { + + /** Lobbies expire after 30 minutes of inactivity. */ + private static final long EXPIRY_MS = 30 * 60 * 1000L; + + private final String gameId; + private final int boardSize; + private final int budget; + private final long expirationTimestamp; + + public Lobby(int boardSize, int budget) { + this.gameId = generateGameId(); + this.boardSize = boardSize; + this.budget = budget; + this.expirationTimestamp = System.currentTimeMillis() + EXPIRY_MS; + } + + /** Used when reconstructing a Lobby from a database snapshot. */ + public Lobby(String gameId, int boardSize, int budget, long expirationTimestamp) { + this.gameId = gameId; + this.boardSize = boardSize; + this.budget = budget; + this.expirationTimestamp = expirationTimestamp; + } + + // ------------------------------------------------------------------------- + // Game ID generation (FR2, FR2.1) + // ------------------------------------------------------------------------- + + /** + * Generates a short, human-readable 6-character uppercase Game ID. + * Full uniqueness is enforced server-side; this is a client-side suggestion. + */ + public static String generateGameId() { + return UUID.randomUUID().toString() + .replace("-", "") + .substring(0, 6) + .toUpperCase(); + } + + // ------------------------------------------------------------------------- + // Expiry (Availability — prevents stale lobbies) + // ------------------------------------------------------------------------- + + public boolean isExpired() { + return System.currentTimeMillis() > expirationTimestamp; + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + public String getGameId() { return gameId; } + public int getBoardSize() { return boardSize; } + public int getBudget() { return budget; } + public long getExpirationTimestamp() { return expirationTimestamp; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/Move.java b/core/src/main/java/com/group14/regicidechess/model/Move.java new file mode 100644 index 0000000..1c6522b --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/Move.java @@ -0,0 +1,33 @@ +package com.group14.regicidechess.model; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.pieces.ChessPiece; + +/** Placement: core/src/main/java/com/group14/regicidechess/model/Move.java */ +public class Move { + + private final Vector2 from; + private final Vector2 to; + private final ChessPiece piece; + private final Player player; + private String promotion; // e.g. "Queen", "Rook", etc. + + public Move(Vector2 from, Vector2 to, ChessPiece piece, Player player) { + this.from = from.cpy(); + this.to = to.cpy(); + this.piece = piece; + this.player = player; + } + + public Move(Vector2 from, Vector2 to, ChessPiece piece, Player player, String promotion) { + this(from, to, piece, player); + this.promotion = promotion; + } + + public Vector2 getFrom() { return from.cpy(); } + public Vector2 getTo() { return to.cpy(); } + public ChessPiece getPiece() { return piece; } + public Player getPlayer() { return player; } + public String getPromotion() { return promotion; } + public void setPromotion(String promotion) { this.promotion = promotion; } +} diff --git a/core/src/main/java/com/group14/regicidechess/model/MoveHistory.java b/core/src/main/java/com/group14/regicidechess/model/MoveHistory.java new file mode 100644 index 0000000..ec9e212 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/MoveHistory.java @@ -0,0 +1,41 @@ +package com.group14.regicidechess.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Placement: core/src/main/java/com/group14/regicidechess/model/MoveHistory.java */ +public class MoveHistory { + + private final List moves = new ArrayList<>(); + private Player currentTurn; + + public MoveHistory(Player firstPlayer) { + this.currentTurn = firstPlayer; + } + + public void append(Move move) { + moves.add(move); + } + + public Player getCurrentTurn() { + return currentTurn; + } + + public void switchTurn(Player playerOne, Player playerTwo) { + currentTurn = (currentTurn == playerOne) ? playerTwo : playerOne; + } + + public List getHistory() { + return Collections.unmodifiableList(moves); + } + + public int getMoveCount() { + return moves.size(); + } + + public Move getLastMove() { + if (moves.isEmpty()) return null; + return moves.get(moves.size() - 1); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/Player.java b/core/src/main/java/com/group14/regicidechess/model/Player.java new file mode 100644 index 0000000..244da6b --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/Player.java @@ -0,0 +1,70 @@ +package com.group14.regicidechess.model; + +/** + * Player — Represents one of the two participants in a match. + * + * Placement: core/src/main/java/com/group14/regicidechess/model/Player.java + */ +public class Player { + + private final String playerId; + private final boolean isWhite; // true = white / host, false = black / joiner + + private int budget; + private int budgetRemaining; + private boolean isReady; + + public Player(String playerId, boolean isWhite, int budget) { + this.playerId = playerId; + this.isWhite = isWhite; + this.budget = budget; + this.budgetRemaining = budget; + this.isReady = false; + } + + // ------------------------------------------------------------------------- + // Budget + // ------------------------------------------------------------------------- + + /** + * Deducts amount from budgetRemaining. + * @return true if the spend succeeded, false if insufficient budget. + */ + public boolean spendBudget(int amount) { + if (amount > budgetRemaining) return false; + budgetRemaining -= amount; + return true; + } + + /** Refunds amount back to budgetRemaining (e.g. piece removed during setup). */ + public void refundBudget(int amount) { + budgetRemaining = Math.min(budget, budgetRemaining + amount); + } + + /** Resets budgetRemaining to the full starting budget. */ + public void resetBudget() { + budgetRemaining = budget; + } + + // ------------------------------------------------------------------------- + // Readiness + // ------------------------------------------------------------------------- + + public void setReady() { + this.isReady = true; + } + + public void resetReady() { + this.isReady = false; + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + public String getPlayerId() { return playerId; } + public boolean isWhite() { return isWhite; } + public int getBudget() { return budget; } + public int getBudgetRemaining() { return budgetRemaining; } + public boolean isReady() { return isReady; } +} diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java new file mode 100644 index 0000000..f0914a6 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java @@ -0,0 +1,40 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Player; + +import java.util.ArrayList; +import java.util.List; + +public class Bishop extends ChessPiece { + + public Bishop(Player owner) { + super(owner, 3); + } + + @Override + public List validMoves() { + List moves = new ArrayList<>(); + slide(moves, 1, 1); slide(moves, 1, -1); + slide(moves, -1, 1); slide(moves, -1, -1); + return moves; + } + + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + if (Math.abs(col - c) != Math.abs(row - r)) return false; + int stepX = (col > c) ? 1 : -1; + int stepY = (row > r) ? 1 : -1; + int x = c + stepX, y = r + stepY; + while (x != col) { + if (board.getPieceAt(x, y) != null) return false; + x += stepX; + y += stepY; + } + return true; + } + + @Override public String getTypeName() { return "Bishop"; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java b/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java new file mode 100644 index 0000000..a4e2e58 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java @@ -0,0 +1,109 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; + +import java.util.List; + +/** + * ChessPiece — Abstract base for all chess pieces. + * + * Placement: core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java + * + * Each concrete subclass implements validMoves() with its own movement rules. + * The Board reference is injected so pieces can check for blocking pieces and + * board boundaries without coupling to any GUI class. + */ +public abstract class ChessPiece { + + protected Vector2 position; + protected Player owner; + protected int pointCost; + + /** Reference to the board — set by Board.placePiece(). */ + protected Board board; + protected boolean hasMoved = false; + + protected ChessPiece(Player owner, int pointCost) { + this.owner = owner; + this.pointCost = pointCost; + this.position = new Vector2(-1, -1); // unplaced sentinel + } + public boolean hasMoved() { return hasMoved; } + public void setHasMoved(boolean moved) { this.hasMoved = moved; } + // ------------------------------------------------------------------------- + // Abstract + // ------------------------------------------------------------------------- + + /** + * Returns all legal destination squares for this piece given the current + * board state. Coordinates are in board space (col, row). + */ + public abstract List validMoves(); + + /** + * Returns true if this piece can attack the given square (col, row) + * regardless of whether moving there would leave its own king in check. + * Used for isSquareAttacked() to avoid recursion and correctly handle + * pawn attacks (diagonal only) vs pawn moves (forward). + */ + public abstract boolean attacksSquare(int col, int row); + + // ------------------------------------------------------------------------- + // Shared sliding helper — used by Rook, Bishop, Queen + // ------------------------------------------------------------------------- + + /** + * Slides in direction (dc, dr) and collects squares until blocked or the + * edge of the board is reached. + * + * @param results list to add destination squares to + * @param dc column delta per step + * @param dr row delta per step + */ + protected void slide(List results, int dc, int dr) { + int c = (int) position.x + dc; + int r = (int) position.y + dr; + + while (board.inBounds(c, r)) { + ChessPiece occupant = board.getPieceAt(c, r); + if (occupant == null) { + results.add(new Vector2(c, r)); + } else { + if (occupant.owner != owner) results.add(new Vector2(c, r)); // capture + break; // blocked either way + } + c += dc; + r += dr; + } + } + + /** + * Adds a single-step destination if in bounds and not occupied by own piece. + */ + protected void step(List results, int dc, int dr) { + int c = (int) position.x + dc; + int r = (int) position.y + dr; + if (!board.inBounds(c, r)) return; + ChessPiece occupant = board.getPieceAt(c, r); + if (occupant == null || occupant.owner != owner) { + results.add(new Vector2(c, r)); + } + } + + // ------------------------------------------------------------------------- + // Getters / setters + // ------------------------------------------------------------------------- + + public Vector2 getPosition() { return position.cpy(); } + public Player getOwner() { return owner; } + public int getPointCost() { return pointCost; } + + public void setPosition(Vector2 pos) { this.position.set(pos); } + public void setPosition(int c, int r) { this.position.set(c, r); } + public void setBoard(Board board) { this.board = board; } + + /** Type name for display / serialisation. */ + public abstract String getTypeName(); +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/King.java b/core/src/main/java/com/group14/regicidechess/model/pieces/King.java new file mode 100644 index 0000000..0da2b5e --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/King.java @@ -0,0 +1,72 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Player; + +import java.util.ArrayList; +import java.util.List; + +public class King extends ChessPiece { + + public King(Player owner) { + super(owner, 0); + } + + @Override + public List validMoves() { + List moves = new ArrayList<>(); + // normal one-step moves + for (int dc = -1; dc <= 1; dc++) + for (int dr = -1; dr <= 1; dr++) + if (dc != 0 || dr != 0) + step(moves, dc, dr); + + // Castling + if (hasMoved) return moves; + if (board.isSquareAttacked((int) position.x, (int) position.y, owner)) + return moves; + + int kingCol = (int) position.x; + int kingRow = (int) position.y; + + for (int c = 0; c < board.getSize(); c++) { + ChessPiece piece = board.getPieceAt(c, kingRow); + if (piece instanceof Rook && piece.getOwner() == owner && !piece.hasMoved()) { + int step = (c > kingCol) ? 1 : -1; + boolean pathClear = true; + for (int x = kingCol + step; x != c; x += step) { + if (board.getPieceAt(x, kingRow) != null) { + pathClear = false; + break; + } + } + if (!pathClear) continue; + if (Math.abs(c - kingCol) < 2) continue; // must move two squares + int destCol = kingCol + 2 * step; + if (destCol < 0 || destCol >= board.getSize()) continue; + + boolean safe = true; + for (int x = Math.min(kingCol, destCol); x <= Math.max(kingCol, destCol); x++) { + if (board.isSquareAttacked(x, kingRow, owner)) { + safe = false; + break; + } + } + if (safe) { + moves.add(new Vector2(destCol, kingRow)); + } + } + } + return moves; + } + + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + return Math.abs(col - c) <= 1 && Math.abs(row - r) <= 1 && !(col == c && row == r); + } + + @Override + public String getTypeName() { return "King"; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java new file mode 100644 index 0000000..c676e70 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java @@ -0,0 +1,33 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Player; + +import java.util.ArrayList; +import java.util.List; + +public class Knight extends ChessPiece { + + public Knight(Player owner) { + super(owner, 3); + } + + @Override + public List validMoves() { + List moves = new ArrayList<>(); + int[][] deltas = { {2,1},{2,-1},{-2,1},{-2,-1},{1,2},{1,-2},{-1,2},{-1,-2} }; + for (int[] d : deltas) step(moves, d[0], d[1]); + return moves; + } + + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + int dx = Math.abs(col - c); + int dy = Math.abs(row - r); + return (dx == 2 && dy == 1) || (dx == 1 && dy == 2); + } + + @Override public String getTypeName() { return "Knight"; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java new file mode 100644 index 0000000..6170400 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java @@ -0,0 +1,71 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Player; + +import java.util.ArrayList; +import java.util.List; + +public class Pawn extends ChessPiece { + + private boolean hasMoved = false; + + public Pawn(Player owner) { + super(owner, 1); + } + + @Override + public List validMoves() { + List moves = new ArrayList<>(); + int dir = owner.isWhite() ? 1 : -1; + int c = (int) position.x; + int r = (int) position.y; + + // One square forward + if (board.inBounds(c, r + dir) && board.getPieceAt(c, r + dir) == null) { + moves.add(new Vector2(c, r + dir)); + // Two squares forward on first move + if (!hasMoved && board.inBounds(c, r + 2 * dir) + && board.getPieceAt(c, r + 2 * dir) == null) { + moves.add(new Vector2(c, r + 2 * dir)); + } + } + + // Diagonal captures + for (int dc : new int[]{ -1, 1 }) { + int tc = c + dc, tr = r + dir; + if (board.inBounds(tc, tr)) { + ChessPiece target = board.getPieceAt(tc, tr); + if (target != null && target.getOwner() != owner) { + moves.add(new Vector2(tc, tr)); + } + } + } + + // En passant + Vector2 enPassantTarget = board.getEnPassantTarget(); + if (enPassantTarget != null && (int) enPassantTarget.y == r + dir) { + for (int dc : new int[]{ -1, 1 }) { + int tc = c + dc; + if (tc == (int) enPassantTarget.x) { + moves.add(new Vector2(tc, r + dir)); + } + } + } + return moves; + } + + @Override + public boolean attacksSquare(int col, int row) { + int dir = owner.isWhite() ? 1 : -1; + int c = (int) position.x; + int r = (int) position.y; + // Pawn attacks one square diagonally forward + return (Math.abs(col - c) == 1 && row == r + dir); + } + + public void markMoved() { hasMoved = true; } + public boolean hasMoved() { return hasMoved; } + + @Override public String getTypeName() { return "Pawn"; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java new file mode 100644 index 0000000..19a5db0 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java @@ -0,0 +1,58 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Player; + +import java.util.ArrayList; +import java.util.List; + +public class Queen extends ChessPiece { + + public Queen(Player owner) { + super(owner, 9); + } + + @Override + public List validMoves() { + List moves = new ArrayList<>(); + slide(moves, 1, 0); slide(moves, -1, 0); + slide(moves, 0, 1); slide(moves, 0, -1); + slide(moves, 1, 1); slide(moves, 1, -1); + slide(moves, -1, 1); slide(moves, -1, -1); + return moves; + } + + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + // Rook-like + if (c == col) { + int step = (row > r) ? 1 : -1; + for (int y = r + step; y != row; y += step) + if (board.getPieceAt(c, y) != null) return false; + return true; + } + if (r == row) { + int step = (col > c) ? 1 : -1; + for (int x = c + step; x != col; x += step) + if (board.getPieceAt(x, r) != null) return false; + return true; + } + // Bishop-like + if (Math.abs(col - c) == Math.abs(row - r)) { + int stepX = (col > c) ? 1 : -1; + int stepY = (row > r) ? 1 : -1; + int x = c + stepX, y = r + stepY; + while (x != col) { + if (board.getPieceAt(x, y) != null) return false; + x += stepX; + y += stepY; + } + return true; + } + return false; + } + + @Override public String getTypeName() { return "Queen"; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java new file mode 100644 index 0000000..39da117 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java @@ -0,0 +1,43 @@ +package com.group14.regicidechess.model.pieces; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Player; + +import java.util.ArrayList; +import java.util.List; + +public class Rook extends ChessPiece { + + public Rook(Player owner) { + super(owner, 5); + } + + @Override + public List validMoves() { + List moves = new ArrayList<>(); + slide(moves, 1, 0); slide(moves, -1, 0); + slide(moves, 0, 1); slide(moves, 0, -1); + return moves; + } + + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + if (c == col) { + int step = (row > r) ? 1 : -1; + for (int y = r + step; y != row; y += step) + if (board.getPieceAt(c, y) != null) return false; + return true; + } + if (r == row) { + int step = (col > c) ? 1 : -1; + for (int x = c + step; x != col; x += step) + if (board.getPieceAt(x, r) != null) return false; + return true; + } + return false; + } + + @Override public String getTypeName() { return "Rook"; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java b/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java new file mode 100644 index 0000000..8fab597 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java @@ -0,0 +1,77 @@ +package com.group14.regicidechess.network; + +/** + * Simple circuit breaker with three states: CLOSED, OPEN, HALF_OPEN. + * + * CLOSED: All requests pass. Consecutive failures are counted. + * OPEN: Requests blocked. After cooldown, transitions to HALF_OPEN. + * HALF_OPEN: One probe request allowed. Success -> CLOSED, failure -> OPEN. + */ +public class CircuitBreaker { + + public enum State { CLOSED, OPEN, HALF_OPEN } + + private State state = State.CLOSED; + private int failureCount = 0; + private long openedAt = 0L; + private boolean halfOpenProbeUsed = false; + + private final int failureThreshold; + private final long cooldownMs; + + public CircuitBreaker(int failureThreshold, long cooldownMs) { + this.failureThreshold = failureThreshold; + this.cooldownMs = cooldownMs; + } + + public synchronized boolean allowRequest() { + advanceIfCooldownElapsed(); + switch (state) { + case CLOSED: + return true; + case HALF_OPEN: + if (!halfOpenProbeUsed) { + halfOpenProbeUsed = true; + return true; + } + return false; + case OPEN: + default: + return false; + } + } + + public synchronized void recordSuccess() { + failureCount = 0; + state = State.CLOSED; + halfOpenProbeUsed = false; + } + + public synchronized void recordFailure() { + failureCount++; + if (state == State.HALF_OPEN || failureCount >= failureThreshold) { + state = State.OPEN; + openedAt = System.currentTimeMillis(); + halfOpenProbeUsed = false; + } + } + + public synchronized State getState() { + advanceIfCooldownElapsed(); + return state; + } + + public synchronized void reset() { + state = State.CLOSED; + failureCount = 0; + openedAt = 0L; + halfOpenProbeUsed = false; + } + + private void advanceIfCooldownElapsed() { + if (state == State.OPEN && System.currentTimeMillis() - openedAt >= cooldownMs) { + state = State.HALF_OPEN; + halfOpenProbeUsed = false; + } + } +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java new file mode 100644 index 0000000..a80347f --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java @@ -0,0 +1,178 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Matrix4; +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.utils.ResourceManager; + +import java.util.List; + +/** + * GameBoardRenderer — draws the chess board, pieces, and selection highlights. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java + * + * Owns the ShapeRenderer. Knows nothing about Firebase, screens, or game logic — + * it is given what to draw via method parameters and the shared board reference. + * + * Coordinate convention: + * Logic space — col/row as used by Board and InMatchState (row 0 = white's back rank). + * Display space — row after optional flip; row 0 is always at the bottom of the screen. + * White: display == logic. Black: display = (size - 1 - logic) so their pieces appear at the bottom. + */ +public class GameBoardRenderer { + + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final Player localPlayer; + private final int boardSize; + + private float boardLeft; + private float boardBottom; + private float cellSize; + + // Tile colours + private static final Color LIGHT_TILE = new Color(0.93f, 0.85f, 0.72f, 1f); + private static final Color DARK_TILE = new Color(0.55f, 0.38f, 0.24f, 1f); + + public GameBoardRenderer(SpriteBatch batch, Player localPlayer, int boardSize) { + this.batch = batch; + this.localPlayer = localPlayer; + this.boardSize = boardSize; + this.shapeRenderer = new ShapeRenderer(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Geometry + // ───────────────────────────────────────────────────────────────────────── + + public void computeGeometry(float screenW, float screenH, + float topBarHeight, float statusBarHeight) { + float available = screenH - topBarHeight - statusBarHeight - 16f; + float maxW = screenW - 16f; + cellSize = Math.min(maxW / boardSize, available / boardSize); + boardLeft = (screenW - cellSize * boardSize) / 2f; + boardBottom = statusBarHeight + (available - cellSize * boardSize) / 2f + 8f; + } + + /** Converts a logic row to a display row for this player's perspective. */ + public int toDisplayRow(int logicRow) { + return localPlayer.isWhite() ? logicRow : (boardSize - 1 - logicRow); + } + + /** Converts a display row back to a logic row. */ + public int toLogicRow(int displayRow) { + return localPlayer.isWhite() ? displayRow : (boardSize - 1 - displayRow); + } + + /** Returns the cell size in pixels (useful for tap detection in GameScreen). */ + public float getCellSize() { return cellSize; } + public float getBoardLeft() { return boardLeft; } + public float getBoardBottom(){ return boardBottom; } + + // ───────────────────────────────────────────────────────────────────────── + // Drawing + // ───────────────────────────────────────────────────────────────────────── + + /** + * Draws the full board: tiles, highlights, grid, and pieces. + * + * @param projMatrix camera combined matrix from the Stage + * @param board current game board + * @param selectedCell logic coords of the selected cell, or null + * @param validMoves logic coords of valid move targets, may be empty + */ + public void draw(Matrix4 projMatrix, Board board, + Vector2 selectedCell, List validMoves) { + shapeRenderer.setProjectionMatrix(projMatrix); + drawTiles(); + drawValidMoveHighlights(validMoves); + drawSelectedHighlight(selectedCell); + drawGrid(); + drawPieces(projMatrix, board); + } + + private void drawTiles() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + for (int col = 0; col < boardSize; col++) { + for (int logicRow = 0; logicRow < boardSize; logicRow++) { + int dispRow = toDisplayRow(logicRow); + float x = boardLeft + col * cellSize; + float y = boardBottom + dispRow * cellSize; + boolean light = (col + logicRow) % 2 == 0; + shapeRenderer.setColor(light ? LIGHT_TILE : DARK_TILE); + shapeRenderer.rect(x, y, cellSize, cellSize); + } + } + shapeRenderer.end(); + } + + private void drawValidMoveHighlights(List validMoves) { + if (validMoves == null || validMoves.isEmpty()) return; + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + shapeRenderer.setColor(new Color(0.35f, 0.75f, 0.35f, 0.55f)); + for (Vector2 m : validMoves) { + shapeRenderer.rect( + boardLeft + m.x * cellSize, + boardBottom + toDisplayRow((int) m.y) * cellSize, + cellSize, cellSize); + } + shapeRenderer.end(); + } + + private void drawSelectedHighlight(Vector2 selectedCell) { + if (selectedCell == null) return; + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + shapeRenderer.setColor(new Color(0.85f, 0.65f, 0.13f, 0.60f)); + shapeRenderer.rect( + boardLeft + selectedCell.x * cellSize, + boardBottom + toDisplayRow((int) selectedCell.y) * cellSize, + cellSize, cellSize); + shapeRenderer.end(); + } + + private void drawGrid() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(new Color(0f, 0f, 0f, 0.25f)); + for (int i = 0; i <= boardSize; i++) { + float x = boardLeft + i * cellSize; + float y = boardBottom + i * cellSize; + shapeRenderer.line(x, boardBottom, x, boardBottom + boardSize * cellSize); + shapeRenderer.line(boardLeft, y, boardLeft + boardSize * cellSize, y); + } + shapeRenderer.end(); + } + + private void drawPieces(Matrix4 projMatrix, Board board) { + batch.setProjectionMatrix(projMatrix); + batch.begin(); + for (ChessPiece piece : board.getPieces()) { + Vector2 pos = piece.getPosition(); + int dispRow = toDisplayRow((int) pos.y); + String color = piece.getOwner().isWhite() ? "white" : "black"; + Texture tex = ResourceManager.getInstance() + .getPieceTexture(color, piece.getTypeName().toLowerCase()); + float pieceSize = cellSize * 0.8f; + float offset = (cellSize - pieceSize) / 2f; + batch.draw(tex, + boardLeft + pos.x * cellSize + offset, + boardBottom + dispRow * cellSize + offset, + pieceSize, pieceSize); + } + batch.end(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────── + + public void dispose() { + shapeRenderer.dispose(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java new file mode 100644 index 0000000..1bc7494 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java @@ -0,0 +1,463 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.utils.Timer; +import com.group14.regicidechess.network.CircuitBreaker; +import com.group14.regicidechess.database.DatabaseManager; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Move; +import com.group14.regicidechess.model.Player; + +/** + * GameNetworkHandler — owns all Firebase subscriptions for an active game. + * + * Disconnect / reconnect grace-period design + * ────────────────────────────────────────── + * When a player loses their Firebase connection the server writes a timestamp + * to games/{gameId}/disconnectedAt/{color} via a pre-registered onDisconnect + * hook (see AndroidFirebase.registerDisconnectGameOver). + * + * From that moment BOTH sides start an independent 10-second countdown: + * + * Disconnected player — local (client-side) selfLossTask. + * If we haven't reconnected within RECONNECT_GRACE_MS + * we show "You Lost" locally. We cannot receive a + * Firebase message while offline, so this must be + * purely local. + * + * Connected player — server-side-triggered (client-side) autoForfeitTask. + * Started when disconnectedAt/{opponentColor} appears + * in Firebase (not just when the heartbeat times out, + * which could be noisy). After RECONNECT_GRACE_MS + * this player writes "disconnect:{loserColor}" to + * games/{gameId}/gameOver via a first-writer-wins + * transaction, then fires onGameOver locally so the + * win screen appears immediately without waiting for + * Firebase to echo it back. + * + * Reconnect path + * ────────────── + * When the disconnected player's Firebase connection comes back: + * 1. selfConnected → true → selfLossTask cancelled. + * 2. api.signalReconnected() clears disconnectedAt/{color} and writes + * reconnected/{color} = true. + * 3. Connected player's listenForOpponentReconnected fires → autoForfeitTask + * cancelled → game resumes. + * + * First-writer-wins + * ───────────────── + * signalGameOver uses a Firebase transaction so only the first reason lands, + * preventing a reconnect race where both timers fire near-simultaneously and + * the second write overwrites the first. + */ +public class GameNetworkHandler { + + public interface Listener { + void onOpponentMove(Vector2 from, Vector2 to, int promoCode); + void onGameOver(String reason); + + /** My own measured RTT to Firebase (shown at bottom of screen). */ + void onMyLatency(long latencyMs); + + /** Opponent's published latency (shown at top of screen). */ + void onOpponentLatency(long latencyMs); + + /** I lost my own Firebase connection. */ + void onSelfDisconnected(); + + /** I regained my own Firebase connection. */ + void onSelfReconnected(); + + /** Opponent's disconnectedAt timestamp appeared in Firebase (they dropped). */ + void onOpponentDisconnected(); + + /** Opponent heartbeat resumed / reconnected flag set — grace timer cancelled. */ + void onOpponentReconnected(); + } + + // Heartbeat sent every 2 s. After HEARTBEAT_TIMEOUT_MS with no heartbeat + // the opponent is considered disconnected at the client level. + // The authoritative disconnect signal for the grace timer is the server-side + // disconnectedAt timestamp written by the onDisconnect hook. + private static final long HEARTBEAT_TIMEOUT_MS = 5_000L; + private static final long RECONNECT_GRACE_MS = 10_000L; + + private final String gameId; + private final Player localPlayer; + private final Listener listener; + private final FirebaseAPI api; + + // Reflects YOUR OWN connection quality — updated only from successful RTT measurements. + private final Image connectionIcon; + private final Label connectionLabel; + + // Circuit breaker for move writes only. + private final CircuitBreaker circuitBreaker = new CircuitBreaker(3, 10_000L); + + // Connected player's grace timer: fires after RECONNECT_GRACE_MS if + // disconnectedAt/{opponentColor} is set and hasn't been cleared. + private Timer.Task autoForfeitTask = null; + + // Disconnected player's local grace timer: fires after RECONNECT_GRACE_MS + // if we haven't regained a Firebase connection. + private Timer.Task selfLossTask = null; + + private boolean opponentDisconnected = false; // true while opponent's disconnectedAt is set + private boolean selfConnected = true; // mirrors .info/connected + private boolean forfeitWritten = false; // prevent double-write to gameOver + + /** + * @param selfConnectedAtStart Pass the connection state inherited from SetupNetworkHandler + * (via isSelfConnected()). This seeds selfConnected correctly so + * the initial .info/connected = true callback — which Firebase + * always fires immediately on listener attach — is not mistaken + * for a reconnect after a disconnect. + * Pass {@code true} for a normal start; pass the actual state + * when transitioning from SetupScreen. + */ + public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, + Image connectionIcon, Label connectionLabel, + FirebaseAPI api, boolean selfConnectedAtStart) { + this.gameId = gameId; + this.localPlayer = localPlayer; + this.listener = listener; + this.connectionIcon = connectionIcon; + this.connectionLabel = connectionLabel; + this.api = api; + this.selfConnected = selfConnectedAtStart; + } + + /** Convenience overload for cases where we know the device is connected (e.g. tests). */ + public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, + Image connectionIcon, Label connectionLabel, FirebaseAPI api) { + this(gameId, localPlayer, listener, connectionIcon, connectionLabel, api, true); + } + + // ───────────────────────────────────────────────────────────────────────── + // Start / stop + // ───────────────────────────────────────────────────────────────────────── + + public void start() { + // Server-side safety net: writes disconnectedAt/{myColor} if we drop. + // This is what starts the connected opponent's grace timer — NOT gameOver. + registerDisconnectSignal(); + startSelfConnectionListener(); + startOpponentMoveListener(); + startGameOverListener(); + startHeartbeatAndLatency(); + startOpponentDisconnectedAtListener(); // listens for disconnectedAt/{opponentColor} + startOpponentReconnectListener(); // listens for reconnected/{opponentColor} + } + + public void stop() { + cancelAutoForfeitTimer(); + cancelSelfLossTimer(); + api.removeAllListeners(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Outgoing + // ───────────────────────────────────────────────────────────────────────── + + public void saveMove(Move move) { + if (!circuitBreaker.allowRequest()) { + Gdx.app.log("GameNetworkHandler", "Circuit breaker OPEN — move blocked"); + return; + } + api.saveMove(gameId, move, () -> circuitBreaker.recordSuccess()); + } + + public void sendHeartbeat() { + api.sendHeartbeat(gameId, localPlayer.isWhite()); + } + + public void signalForfeit() { + if (forfeitWritten) return; + forfeitWritten = true; + String loser = localPlayer.isWhite() ? "white" : "black"; + api.signalGameOver(gameId, "forfeit:" + loser); + } + + // ───────────────────────────────────────────────────────────────────────── + // Server-side disconnect signal (Firebase onDisconnect hook) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Registers a Firebase onDisconnect write to + * games/{gameId}/disconnectedAt/{myColor}. + * + * This is the authoritative signal that tells the connected opponent to + * start their RECONNECT_GRACE_MS countdown. It does NOT write to gameOver — + * only the connected player's autoForfeitTimer does that, after the full + * grace window has elapsed without a reconnect. + * + * The hook is cancelled in signalReconnected() if we come back online, + * and in signalGameOver() for clean game endings. + */ + private void registerDisconnectSignal() { + String myColor = localPlayer.isWhite() ? "white" : "black"; + // We pass "disconnect:{myColor}" as the reason string; AndroidFirebase + // parses the color and writes it to disconnectedAt/{color} rather than + // gameOver, so there is no instant loss. + api.registerDisconnectGameOver(gameId, "disconnect:" + myColor); + } + + // ───────────────────────────────────────────────────────────────────────── + // Own connection — .info/connected + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfConnectionListener() { + api.listenForMyConnection( + // onConnected + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) { + selfConnected = true; + Gdx.app.log("GameNetworkHandler", "Self reconnected to Firebase"); + // Cancel local loss countdown — we made it back in time + cancelSelfLossTimer(); + // Tell Firebase (and the opponent) that we're back. + // AndroidFirebase.signalReconnected also cancels the onDisconnect + // hook and clears disconnectedAt so the opponent's timer stops. + api.signalReconnected(gameId, localPlayer.isWhite()); + listener.onSelfReconnected(); + } + }), + // onDisconnected + () -> Gdx.app.postRunnable(() -> { + if (selfConnected) { + selfConnected = false; + Gdx.app.log("GameNetworkHandler", "Self disconnected from Firebase"); + cancelAutoForfeitTimer(); // we can't be the connected player while offline + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + // Start local countdown — if we don't reconnect in time, show "You Lost" + startSelfLossTimer(); + listener.onSelfDisconnected(); + } + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Incoming listeners + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentMoveListener() { + api.listenForOpponentMove(gameId, coords -> { + boolean moveIsWhite = coords.length > 4 && coords[4] == 1; + if (moveIsWhite == localPlayer.isWhite()) return; + Vector2 from = new Vector2(coords[0], coords[1]); + Vector2 to = new Vector2(coords[2], coords[3]); + int promoCode = coords.length > 5 ? coords[5] : 0; + Gdx.app.postRunnable(() -> listener.onOpponentMove(from, to, promoCode)); + }); + } + + private void startGameOverListener() { + api.listenForGameOver(gameId, reason -> { + // "cancel:..." is written by SetupNetworkHandler during the setup phase. + // Ignore it — GameScreen only handles "forfeit:..." and "disconnect:...". + if (reason == null || reason.startsWith("cancel:")) return; + Gdx.app.postRunnable(() -> listener.onGameOver(reason)); + }); + } + + private void startHeartbeatAndLatency() { + boolean watchOpponent = !localPlayer.isWhite(); + + api.listenForHeartbeat( + gameId, + watchOpponent, + HEARTBEAT_TIMEOUT_MS, + // ── Heartbeat received ──────────────────────────────────────── + latency -> Gdx.app.postRunnable(() -> { + boolean wasDisconnected = opponentDisconnected; + opponentDisconnected = false; + cancelAutoForfeitTimer(); + circuitBreaker.recordSuccess(); + + updateMyConnectionUI(latency); + listener.onMyLatency(latency); + api.sendLatency(gameId, localPlayer.isWhite(), latency); + + if (wasDisconnected) { + // Heartbeat resumed before grace timer fired — opponent is back. + // listenForOpponentReconnected will also fire via signalReconnected, + // but handling it here too keeps the UI snappy. + listener.onOpponentReconnected(); + } + }), + // ── Timeout: no heartbeat from opponent for HEARTBEAT_TIMEOUT_MS ─ + // This is the PRIMARY trigger for the connected player's grace timer. + // Firebase's onDisconnect hook (disconnectedAt) is a secondary fallback + // but can take 60+ seconds on mobile — far too slow to be the main signal. + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) { + Gdx.app.log("GameNetworkHandler", + "Heartbeat timeout ignored — self is offline"); + return; + } + if (opponentDisconnected) return; // grace timer already running + + opponentDisconnected = true; + circuitBreaker.recordFailure(); + Gdx.app.log("GameNetworkHandler", + "Heartbeat timeout — opponent disconnected, starting 10s grace timer"); + listener.onOpponentDisconnected(); + startAutoForfeitTimer(); + }) + ); + + api.listenForOpponentLatency(gameId, watchOpponent, + latency -> Gdx.app.postRunnable(() -> listener.onOpponentLatency(latency))); + } + + // ───────────────────────────────────────────────────────────────────────── + // disconnectedAt listener — secondary fallback (connected player only) + // + // Fires when games/{gameId}/disconnectedAt/{opponentColor} is written by + // the server's onDisconnect hook. Firebase may take 60+ seconds to detect + // a dropped TCP connection on mobile, so the heartbeat timeout above is the + // PRIMARY trigger for the grace timer. This listener catches edge cases where + // the heartbeat timeout misfires or the opponent's app is killed without the + // TCP stack notifying Firebase cleanly. + // + // Both paths guard with `if (opponentDisconnected) return` so only the first + // one starts the timer. + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentDisconnectedAtListener() { + String opponentColor = localPlayer.isWhite() ? "black" : "white"; + api.listenForOpponentDisconnectedAt(gameId, opponentColor, () -> + Gdx.app.postRunnable(() -> { + if (opponentDisconnected) return; // heartbeat already started the timer + if (!selfConnected) return; // we're the ones offline + opponentDisconnected = true; + Gdx.app.log("GameNetworkHandler", + "disconnectedAt signal received — starting grace timer (fallback path)"); + listener.onOpponentDisconnected(); + startAutoForfeitTimer(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Opponent reconnect listener (connected player only) + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentReconnectListener() { + boolean watchWhite = !localPlayer.isWhite(); + api.listenForOpponentReconnected(gameId, watchWhite, () -> + Gdx.app.postRunnable(() -> { + Gdx.app.log("GameNetworkHandler", + "Opponent signalled reconnect — cancelling forfeit timer"); + cancelAutoForfeitTimer(); + opponentDisconnected = false; + // Only notify the listener if the timer was actually running, + // i.e. the heartbeat timeout or disconnectedAt signal had fired. + // The heartbeat callback above also calls onOpponentReconnected + // when it resumes, so both paths are covered. + listener.onOpponentReconnected(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Connected player's auto-forfeit timer + // + // Started when disconnectedAt/{opponentColor} appears (server confirms drop). + // Cancelled if reconnected/{opponentColor} appears within the grace window. + // On expiry: writes "disconnect:{loserColor}" to gameOver (first-writer-wins + // transaction) and fires onGameOver locally so the win screen is immediate. + // ───────────────────────────────────────────────────────────────────────── + + private void startAutoForfeitTimer() { + cancelAutoForfeitTimer(); + autoForfeitTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (!opponentDisconnected || forfeitWritten || !selfConnected) return; + forfeitWritten = true; + String disconnectedColor = localPlayer.isWhite() ? "black" : "white"; + Gdx.app.log("GameNetworkHandler", + "Grace period expired — writing disconnect:" + disconnectedColor); + api.signalGameOver(gameId, "disconnect:" + disconnectedColor); + // Deliver locally so the connected player sees "You Win" immediately + // without waiting for Firebase to echo it back. + listener.onGameOver("disconnect:" + disconnectedColor); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelAutoForfeitTimer() { + if (autoForfeitTask != null) { + autoForfeitTask.cancel(); + autoForfeitTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Disconnected player's local loss timer + // + // Started when .info/connected goes false (we lost Firebase). + // Cancelled if .info/connected returns true within the grace window. + // On expiry: shows "You Lost" locally — no Firebase write (we're offline). + // The server-side onDisconnect hook ensures the opponent sees the result + // via their own autoForfeitTimer once the grace period passes. + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfLossTimer() { + cancelSelfLossTimer(); + selfLossTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (selfConnected) return; // reconnected in time — do nothing + Gdx.app.log("GameNetworkHandler", + "Self reconnect grace expired — showing loss locally"); + // Show loss locally without any Firebase write — we're offline. + // The server-side onDisconnect hook has already written + // disconnectedAt/{myColor}, which will trigger the connected + // player's autoForfeitTimer to write the gameOver reason. + listener.onGameOver( + "disconnect:" + (localPlayer.isWhite() ? "white" : "black")); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelSelfLossTimer() { + if (selfLossTask != null) { + selfLossTask.cancel(); + selfLossTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // YOUR OWN connection indicator + // ───────────────────────────────────────────────────────────────────────── + + private void updateMyConnectionUI(long latencyMs) { + CircuitBreaker.State state = circuitBreaker.getState(); + if (state == CircuitBreaker.State.OPEN) { + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Error"); + return; + } + if (state == CircuitBreaker.State.HALF_OPEN) { + connectionIcon.setColor(Color.ORANGE); + connectionLabel.setText("Retrying"); + return; + } + if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); + else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE); + else connectionIcon.setColor(Color.RED); + connectionLabel.setText(latencyMs + " ms"); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java new file mode 100644 index 0000000..7d90927 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java @@ -0,0 +1,244 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.scenes.scene2d.InputEvent; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; +import com.badlogic.gdx.utils.Align; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * GameOverlayManager — builds and controls all modal overlays in the game screen. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java + * + * Manages three overlays: + * 1. General overlay — forfeit confirmation and game-over summary. + * 2. Promotion overlay — piece selection when a pawn reaches the back rank. + * + * Callers supply callbacks; this class owns no game logic. + */ +public class GameOverlayManager { + + /** Callbacks surfaced to GameScreen. */ + public interface Listener { + void onForfeitConfirmed(); + void onGameOverBack(); + void onPromotionChosen(String pieceName); + } + + private final Stage stage; + private final Skin skin; + private final Player localPlayer; + private final Listener listener; + + // ── General overlay widgets ─────────────────────────────────────────────── + private Table overlayWrapper; + private Label overlayTitle; + private Label overlayBody; + private TextButton overlayConfirmBtn; + private TextButton overlayCancelBtn; + private Runnable onOverlayConfirm; + + // ── Promotion overlay ───────────────────────────────────────────────────── + private Table promotionOverlay; + + public GameOverlayManager(Stage stage, Skin skin, Player localPlayer, Listener listener) { + this.stage = stage; + this.skin = skin; + this.localPlayer = localPlayer; + this.listener = listener; + + buildGeneralOverlay(); + buildPromotionOverlay(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Visibility queries (used by GameScreen to block board taps) + // ───────────────────────────────────────────────────────────────────────── + + public boolean isAnyOverlayVisible() { + return overlayWrapper.isVisible() || promotionOverlay.isVisible(); + } + + public boolean isGeneralOverlayVisible() { + return overlayWrapper.isVisible(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Show helpers + // ───────────────────────────────────────────────────────────────────────── + + public void showForfeitConfirm() { + overlayTitle.setText("Forfeit?"); + overlayBody.setText("Are you sure you want to give up?"); + overlayConfirmBtn.setText("Yes, forfeit"); + overlayCancelBtn.setVisible(true); + onOverlayConfirm = listener::onForfeitConfirmed; + overlayWrapper.setVisible(true); + } + + public void showGameOver(boolean localWon) { + overlayTitle.setText(localWon ? "You Win!" : "Game Over"); + overlayBody.setText(localWon + ? "You captured the opponent's King!" + : "Your King was captured."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + + public void showOpponentDisconnected() { + overlayTitle.setText("You Win!"); + overlayBody.setText("Opponent disconnected."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + + /** + * Called when a forfeit/disconnect reason arrives from Firebase. + * reason format: "forfeit:{loserColor}" | "disconnect:{droppedColor}" + */ + public void showForfeitReceived(String reason) { + String myColor = localPlayer.isWhite() ? "white" : "black"; + String opponentColor = localPlayer.isWhite() ? "black" : "white"; + + if (reason.equals("disconnect:" + myColor)) { + // I was the one who dropped and couldn't reconnect in time + overlayTitle.setText("You Lost"); + overlayBody.setText("You disconnected and could not reconnect in time."); + } else if (reason.equals("disconnect:" + opponentColor)) { + // Opponent dropped and didn't reconnect + overlayTitle.setText("You Win!"); + overlayBody.setText("Opponent disconnected."); + } else if (reason.equals("forfeit:" + opponentColor)) { + // Opponent pressed Forfeit + overlayTitle.setText("You Win!"); + overlayBody.setText("Opponent forfeited."); + } else if (reason.equals("forfeit:" + myColor)) { + // My own forfeit echoed back from Firebase — isGameOver guard in GameScreen + // should prevent this path, but handle it defensively. + overlayTitle.setText("You Lost"); + overlayBody.setText("You forfeited."); + } else { + // Unrecognised reason (e.g. stale "cancel:" from setup — should be filtered + // in GameNetworkHandler, but guard here too). + return; + } + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + + /** Shown immediately to the local player after they confirm the forfeit dialog. */ + public void showSelfForfeited() { + overlayTitle.setText("You Lost"); + overlayBody.setText("You forfeited."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + + public void showPromotion() { + promotionOverlay.setVisible(true); + } + + // ───────────────────────────────────────────────────────────────────────── + // Construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildGeneralOverlay() { + overlayWrapper = new Table(); + overlayWrapper.setFillParent(true); + overlayWrapper.setVisible(false); + stage.addActor(overlayWrapper); + + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(32); + + overlayTitle = new Label("", skin, "title"); + overlayTitle.setAlignment(Align.center); + overlayBody = new Label("", skin, "default"); + overlayBody.setAlignment(Align.center); + overlayBody.setWrap(true); + + overlayConfirmBtn = new TextButton("", skin, "accent"); + overlayCancelBtn = new TextButton("Cancel", skin, "default"); + + card.add(overlayTitle).expandX().padBottom(16).row(); + card.add(overlayBody).width(300).padBottom(32).row(); + + Table btnRow = new Table(); + btnRow.add(overlayCancelBtn).width(130).height(55).padRight(12); + btnRow.add(overlayConfirmBtn).width(130).height(55); + card.add(btnRow).row(); + + overlayWrapper.add(card).center(); + + overlayConfirmBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + overlayWrapper.setVisible(false); + if (onOverlayConfirm != null) onOverlayConfirm.run(); + } + }); + overlayCancelBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + overlayWrapper.setVisible(false); + } + }); + } + + private void buildPromotionOverlay() { + promotionOverlay = new Table(); + promotionOverlay.setFillParent(true); + promotionOverlay.setVisible(false); + stage.addActor(promotionOverlay); + + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(24); + + Label title = new Label("Promote Pawn", skin, "title"); + title.setAlignment(Align.center); + card.add(title).colspan(4).padBottom(20).row(); + + String color = localPlayer.isWhite() ? "white" : "black"; + String[] names = { "Queen", "Rook", "Bishop", "Knight" }; + + for (String pieceName : names) { + Table btn = new Table(); + btn.setBackground(skin.getDrawable("primary-pixel")); + btn.pad(8); + + Texture tex = ResourceManager.getInstance() + .getPieceTexture(color, pieceName.toLowerCase()); + btn.add(new Image(tex)).size(60).row(); + btn.add(new Label(pieceName, skin, "small")).row(); + + btn.setTouchable(com.badlogic.gdx.scenes.scene2d.Touchable.enabled); + btn.addListener(new ClickListener() { + @Override public void clicked(InputEvent event, float x, float y) { + promotionOverlay.setVisible(false); + listener.onPromotionChosen(pieceName); + } + }); + card.add(btn).size(100).pad(8); + } + + promotionOverlay.add(card).center(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java new file mode 100644 index 0000000..ce50254 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java @@ -0,0 +1,609 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Move; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.screens.game.GameBoardRenderer; +import com.group14.regicidechess.screens.game.GameNetworkHandler; +import com.group14.regicidechess.screens.game.GameOverlayManager; +import com.group14.regicidechess.screens.game.PieceFactory; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; +import com.group14.regicidechess.states.InMatchState; +import com.group14.regicidechess.utils.ResourceManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +/** + * GameScreen — thin coordinator for an active chess match. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/GameScreen.java + * + * Responsibilities: + * - LibGDX Screen lifecycle (show, render, resize, dispose) + * - Building top-bar and status-bar UI + * - Board tap input and selection FSM + * - Delegating rendering → GameBoardRenderer + * - Delegating overlays → GameOverlayManager + * - Delegating Firebase I/O → GameNetworkHandler + * - Piece promotion logic → PieceFactory + */ +public class GameScreen implements Screen, + ScreenInputHandler.ScreenInputObserver, + GameOverlayManager.Listener, + GameNetworkHandler.Listener { + + // ── Layout ──────────────────────────────────────────────────────────────── + private static final float V_WIDTH = 480f; + private static final float V_HEIGHT = 854f; + private static final float TOP_BAR_HEIGHT = 70f; + private static final float STATUS_BAR_HEIGHT = 60f; + private static final long HEARTBEAT_INTERVAL_MS = 2000L; // 2 seconds + + // ── LibGDX ──────────────────────────────────────────────────────────────── + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // ── Game state ──────────────────────────────────────────────────────────── + private final InMatchState inMatchState; + private final Player localPlayer; + private final String gameId; + private final int boardSize; + + // ── Selection FSM (logic coordinates) ──────────────────────────────────── + private Vector2 selectedCell = null; + private List validMoves = new ArrayList<>(); + + // ── Pending promotion ───────────────────────────────────────────────────── + private Move pendingPromotionMove = null; + + // ── Helpers ─────────────────────────────────────────────────────────────── + private final GameBoardRenderer boardRenderer; + private final GameOverlayManager overlayManager; + private final GameNetworkHandler networkHandler; + + // ── Widgets ─────────────────────────────────────────────────────────────── + private TextButton forfeitBtn; + private Label turnLabel; + private Label statusLabel; + private Label myLatencyLabel; + private Label opponentLatencyLabel; + + // #8: Fixed - Use Timer instead of render-loop delta for heartbeats + private Timer heartbeatTimer; + private boolean isHeartbeatRunning = false; + private boolean isScreenActive = true; + + // Countdown shown to the disconnected player during the reconnect grace window + private Timer reconnectCountdownTimer; + private int reconnectSecondsLeft = 0; + + // Track if game is over to prevent multiple triggers + private boolean isGameOver = false; + + // ───────────────────────────────────────────────────────────────────────── + // Constructor + // ───────────────────────────────────────────────────────────────────────── + + /** + * @param selfConnectedAtStart Connection state at the moment of transition, taken from + * SetupNetworkHandler.isSelfConnected(). Seeding this correctly + * prevents the initial .info/connected = true callback that + * Firebase fires on listener attach from being misread as a + * reconnect after a disconnect, which would cancel the grace timer. + * Use the single-arg overload (defaults to true) when constructing + * GameScreen outside of a setup→game transition (e.g. in tests). + */ + public GameScreen(Game game, SpriteBatch batch, + Board board, Player localPlayer, int boardSize, String gameId, + FirebaseAPI api, boolean selfConnectedAtStart) { + this.game = game; + this.batch = batch; + this.localPlayer = localPlayer; + this.gameId = gameId; + this.boardSize = boardSize; + + Player opponent = new Player( + localPlayer.isWhite() ? "player2" : "player1", + !localPlayer.isWhite(), + localPlayer.getBudget()); + + inMatchState = new InMatchState(); + inMatchState.init(board, + localPlayer.isWhite() ? localPlayer : opponent, + localPlayer.isWhite() ? opponent : localPlayer); + inMatchState.enter(); + + stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); + skin = ResourceManager.getInstance().getSkin(); + inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + Image connectionIcon = new Image(skin.getDrawable("white-pixel")); + Label connectionLabel = new Label("Connecting", skin, "small"); + connectionIcon.setColor(Color.GRAY); + + buildUI(connectionIcon, connectionLabel); + + overlayManager = new GameOverlayManager(stage, skin, localPlayer, this); + networkHandler = new GameNetworkHandler(gameId, localPlayer, this, + connectionIcon, connectionLabel, api, + selfConnectedAtStart); + boardRenderer = new GameBoardRenderer(batch, localPlayer, boardSize); + boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT); + + networkHandler.start(); + refreshTurnLabel(); + } + + // ───────────────────────────────────────────────────────────────────────── + // UI construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildUI(Image connectionIcon, Label connectionLabel) { + Table root = new Table(); + root.setFillParent(true); + root.top(); + stage.addActor(root); + + Table topBar = new Table(); + topBar.setBackground(skin.getDrawable("primary-pixel")); + topBar.pad(10); + + forfeitBtn = new TextButton("Forfeit", skin, "danger"); + forfeitBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + overlayManager.showForfeitConfirm(); + } + }); + + turnLabel = new Label("", skin, "title"); + turnLabel.setAlignment(Align.center); + + topBar.add(forfeitBtn).width(100).height(50).left(); + topBar.add(turnLabel).expandX().center(); + + opponentLatencyLabel = new Label("Opp: --", skin, "small"); + opponentLatencyLabel.setAlignment(Align.right); + + Table connGroup = new Table(); + connGroup.add(connectionIcon).size(12, 12).padRight(6); + connGroup.add(connectionLabel).right(); + connGroup.row(); + connGroup.add(opponentLatencyLabel).colspan(2).right(); + + topBar.add(connGroup).width(110).right().padRight(8); + + root.add(topBar).expandX().fillX().height(TOP_BAR_HEIGHT).row(); + root.add().expandX().expandY().row(); // spacer for board area + + Table statusBar = new Table(); + statusBar.setBackground(skin.getDrawable("surface-pixel")); + statusBar.pad(10); + statusLabel = new Label("Select a piece to move.", skin, "small"); + statusLabel.setAlignment(Align.center); + myLatencyLabel = new Label("Ping: --", skin, "small"); + myLatencyLabel.setAlignment(Align.right); + statusBar.add(statusLabel).expandX(); + statusBar.add(myLatencyLabel).width(80).right(); + root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); + } + + // ───────────────────────────────────────────────────────────────────────── + // #8: Fixed - Heartbeat timer management + // ───────────────────────────────────────────────────────────────────────── + + private void startHeartbeatTimer() { + if (isHeartbeatRunning) return; + + heartbeatTimer = new Timer(true); + heartbeatTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + if (isScreenActive && !isGameOver) { + Gdx.app.postRunnable(() -> { + if (!isGameOver) { + networkHandler.sendHeartbeat(); + } + }); + } + } + }, 0, HEARTBEAT_INTERVAL_MS); + + isHeartbeatRunning = true; + Gdx.app.log("GameScreen", "Heartbeat timer started with interval: " + HEARTBEAT_INTERVAL_MS + "ms"); + } + + private void stopHeartbeatTimer() { + if (heartbeatTimer != null) { + heartbeatTimer.cancel(); + heartbeatTimer = null; + } + isHeartbeatRunning = false; + Gdx.app.log("GameScreen", "Heartbeat timer stopped"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Rendering + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void render(float delta) { + Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + inMatchState.update(delta); + + boardRenderer.draw(stage.getCamera().combined, + inMatchState.getBoard(), selectedCell, validMoves); + + stage.act(delta); + stage.draw(); + + if (overlayManager.isAnyOverlayVisible()) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + ShapeRenderer sr = new ShapeRenderer(); + sr.setProjectionMatrix(stage.getCamera().combined); + sr.begin(ShapeRenderer.ShapeType.Filled); + sr.setColor(0f, 0f, 0f, 0.65f); + sr.rect(0, 0, V_WIDTH, V_HEIGHT); + sr.end(); + sr.dispose(); + Gdx.gl.glDisable(GL20.GL_BLEND); + stage.draw(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Input — ScreenInputObserver + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int screenX, int screenY, int pointer, int button) { + if (overlayManager.isAnyOverlayVisible()) return; + Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); + handleBoardTap(world.x, world.y); + } + @Override public void onDrag (int x, int y, int pointer) {} + @Override public void onRelease(int x, int y, int pointer, int button) {} + @Override public void onKeyDown(int keycode) {} + + private void handleBoardTap(float worldX, float worldY) { + if (!inMatchState.isMyTurn(localPlayer)) { + showStatus("Not your turn."); + return; + } + + float cellSize = boardRenderer.getCellSize(); + float boardLeft = boardRenderer.getBoardLeft(); + float boardBot = boardRenderer.getBoardBottom(); + int size = inMatchState.getBoard().getSize(); + + if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; + if (worldY < boardBot || worldY > boardBot + size * cellSize) return; + + int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int)((worldY - boardBot) / cellSize), size - 1)); + int logicRow = boardRenderer.toLogicRow(dispRow); + + Vector2 tapped = new Vector2(col, logicRow); + + if (selectedCell == null) { + trySelect(tapped); + } else if (containsVector(validMoves, tapped)) { + executeMove(selectedCell, tapped); + } else { + ChessPiece occupant = inMatchState.getBoard().getPieceAt(col, logicRow); + if (occupant != null && occupant.getOwner() == localPlayer) { + trySelect(tapped); + } else { + deselect(); + } + } + } + + private void trySelect(Vector2 cell) { + ChessPiece piece = inMatchState.getBoard().getPieceAt((int) cell.x, (int) cell.y); + if (piece == null || piece.getOwner() != localPlayer) { + showStatus("Select one of your own pieces."); + deselect(); + return; + } + selectedCell = cell; + validMoves = piece.validMoves(); + showStatus("Selected: " + piece.getTypeName() + " — " + validMoves.size() + " move(s)."); + } + + private void deselect() { + selectedCell = null; + validMoves.clear(); + showStatus("Select a piece to move."); + } + + // ───────────────────────────────────────────────────────────────────────── + // Move execution + // ───────────────────────────────────────────────────────────────────────── + + private void executeMove(Vector2 from, Vector2 to) { + ChessPiece movingPiece = inMatchState.getBoard().getPieceAt(from); + if (movingPiece == null) { deselect(); return; } + + ChessPiece captured = inMatchState.executeMove(from, to); + deselect(); + + if (captured instanceof King) { + networkHandler.saveMove(new Move(from, to, movingPiece, localPlayer)); + refreshTurnLabel(); + triggerGameOver(true); + + } else if (isPawnPromotion(movingPiece, to)) { + pendingPromotionMove = new Move(from, to, movingPiece, localPlayer); + overlayManager.showPromotion(); + + } else { + networkHandler.saveMove(new Move(from, to, movingPiece, localPlayer)); + refreshTurnLabel(); + showStatus("Waiting for opponent..."); + } + } + + private boolean isPawnPromotion(ChessPiece piece, Vector2 to) { + if (!(piece instanceof Pawn)) return false; + int rank = piece.getOwner().isWhite() ? inMatchState.getBoard().getSize() - 1 : 0; + return (int) to.y == rank; + } + + private void triggerGameOver(boolean localWon) { + if (isGameOver) return; + isGameOver = true; + stopHeartbeatTimer(); + forfeitBtn.setVisible(false); + overlayManager.showGameOver(localWon); + } + + // ───────────────────────────────────────────────────────────────────────── + // GameOverlayManager.Listener + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onForfeitConfirmed() { + if (isGameOver) return; + isGameOver = true; + stopHeartbeatTimer(); + forfeitBtn.setVisible(false); + networkHandler.signalForfeit(); + // Show "You forfeited" immediately — the Firebase echo of our own + // forfeit signal must NOT trigger onGameOver again (isGameOver guards it). + overlayManager.showSelfForfeited(); + } + + @Override + public void onGameOverBack() { + stopHeartbeatTimer(); + networkHandler.stop(); + game.setScreen(new MainMenuScreen(game, batch)); + } + + /** + * Promotion piece chosen by the local player. + * Replaces the pawn on the board and sends the complete move to Firebase. + * Turn was already switched by executeMove(); just refresh the label. + */ + @Override + public void onPromotionChosen(String pieceName) { + if (pendingPromotionMove == null) return; + + ChessPiece promoted = PieceFactory.fromName(pieceName, localPlayer); + Vector2 toCell = pendingPromotionMove.getTo(); + inMatchState.getBoard().placePiece(promoted, (int) toCell.x, (int) toCell.y); + + networkHandler.saveMove(new Move( + pendingPromotionMove.getFrom(), + pendingPromotionMove.getTo(), + pendingPromotionMove.getPiece(), + localPlayer, + pieceName + )); + + refreshTurnLabel(); + showStatus("Waiting for opponent..."); + pendingPromotionMove = null; + } + + // ───────────────────────────────────────────────────────────────────────── + // GameNetworkHandler.Listener + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onOpponentMove(Vector2 from, Vector2 to, int promoCode) { + ChessPiece piece = inMatchState.getBoard().getPieceAt(from); + if (piece == null || piece.getOwner() == localPlayer) return; + + Player opponent = piece.getOwner(); + ChessPiece captured = inMatchState.executeMove(from, to); + + if (promoCode != 0) { + ChessPiece promoted = PieceFactory.fromCode(promoCode, opponent); + if (promoted != null) { + inMatchState.getBoard().placePiece(promoted, (int) to.x, (int) to.y); + } + } + + deselect(); + refreshTurnLabel(); + + if (captured instanceof King) triggerGameOver(false); + else showStatus("Your turn!"); + } + + @Override + public void onGameOver(String reason) { + if (isGameOver) return; + isGameOver = true; + stopHeartbeatTimer(); + networkHandler.stop(); + forfeitBtn.setVisible(false); + overlayManager.showForfeitReceived(reason); + } + + @Override + public void onMyLatency(long latencyMs) { + Gdx.app.postRunnable(() -> myLatencyLabel.setText("Ping: " + latencyMs + " ms")); + } + + @Override + public void onOpponentLatency(long latencyMs) { + Gdx.app.postRunnable(() -> opponentLatencyLabel.setText("Opp: " + latencyMs + " ms")); + } + + @Override + public void onSelfDisconnected() { + stopHeartbeatTimer(); + Gdx.app.postRunnable(() -> myLatencyLabel.setText("Ping: Lost")); + startReconnectCountdown(); + } + + @Override + public void onSelfReconnected() { + stopReconnectCountdown(); + startHeartbeatTimer(); + networkHandler.sendHeartbeat(); + Gdx.app.postRunnable(() -> myLatencyLabel.setText("Ping: --")); + showStatus("Reconnected!"); + } + + @Override + public void onOpponentDisconnected() { + Gdx.app.postRunnable(() -> opponentLatencyLabel.setText("Opp: Lost")); + showStatus("Opponent disconnected. Waiting for reconnection..."); + } + + @Override + public void onOpponentReconnected() { + showStatus("Opponent reconnected."); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private void refreshTurnLabel() { + turnLabel.setText(inMatchState.isMyTurn(localPlayer) ? "Your turn" : "Opponent's turn"); + } + + private void showStatus(String msg) { + Gdx.app.postRunnable(() -> statusLabel.setText(msg)); + } + + private boolean containsVector(List list, Vector2 v) { + for (Vector2 item : list) + if ((int) item.x == (int) v.x && (int) item.y == (int) v.y) return true; + return false; + } + + // ───────────────────────────────────────────────────────────────────────── + // Reconnect countdown (shown to the disconnected player) + // ───────────────────────────────────────────────────────────────────────── + + private void startReconnectCountdown() { + stopReconnectCountdown(); + reconnectSecondsLeft = 10; + showStatus("You are disconnected! Reconnecting... " + reconnectSecondsLeft + "s"); + reconnectCountdownTimer = new Timer(true); + reconnectCountdownTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + reconnectSecondsLeft--; + if (reconnectSecondsLeft <= 0) { + Gdx.app.postRunnable(() -> showStatus("Disconnected.")); + stopReconnectCountdown(); + } else { + showStatus("You are disconnected! Reconnecting... " + reconnectSecondsLeft + "s"); + } + } + }, 1000L, 1000L); + } + + private void stopReconnectCountdown() { + if (reconnectCountdownTimer != null) { + reconnectCountdownTimer.cancel(); + reconnectCountdownTimer = null; + } + reconnectSecondsLeft = 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + isScreenActive = true; + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + startHeartbeatTimer(); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT); + } + + @Override + public void pause() { + isScreenActive = false; + } + + @Override + public void resume() { + isScreenActive = true; + networkHandler.sendHeartbeat(); + } + + @Override + public void hide() { + isScreenActive = false; + inputHandler.clearObservers(); + stopReconnectCountdown(); + stopHeartbeatTimer(); + networkHandler.stop(); + inMatchState.exit(); + } + + @Override + public void dispose() { + stopReconnectCountdown(); + stopHeartbeatTimer(); + stage.dispose(); + boardRenderer.dispose(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java b/core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java new file mode 100644 index 0000000..b8418e5 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java @@ -0,0 +1,59 @@ +package com.group14.regicidechess.screens.game; + +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; + +/** + * PieceFactory — stateless helpers for building promotion pieces. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java + * + * Promotion codes (shared between GameScreen and AndroidFirebase): + * 2 = Queen, 3 = Rook, 4 = Bishop, 5 = Knight + */ +public final class PieceFactory { + + private PieceFactory() {} + + /** + * Builds a promotion piece from a display name. + * Falls back to Knight for unrecognised names. + */ + public static ChessPiece fromName(String name, Player owner) { + switch (name) { + case "Queen": return new Queen(owner); + case "Rook": return new Rook(owner); + case "Bishop": return new Bishop(owner); + default: return new Knight(owner); + } + } + + /** + * Builds a promotion piece from a Firebase promotion code. + * Returns null for code 0 (no promotion). + */ + public static ChessPiece fromCode(int code, Player owner) { + switch (code) { + case 2: return new Queen(owner); + case 3: return new Rook(owner); + case 4: return new Bishop(owner); + case 5: return new Knight(owner); + default: return null; + } + } + + /** Returns the Firebase promotion code for a piece name. */ + public static int toCode(String name) { + switch (name) { + case "Queen": return 2; + case "Rook": return 3; + case "Bishop": return 4; + case "Knight": return 5; + default: return 0; + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java new file mode 100644 index 0000000..882840a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java @@ -0,0 +1,67 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.Gdx; +import com.group14.regicidechess.states.LobbyState; + +/** + * Manages Firebase listeners and flow between host and joiner. + * Encapsulates all Firebase communication logic. + */ +public class LobbyFlowController { + + public interface FlowListener { + void onJoinerArrived(); // Host: someone joined the lobby + void onGameStarted(); // Joiner: host started the game + void onError(String message); + } + + private final LobbyState lobbyState; + private final FlowListener listener; + private String activeGameId; + + public LobbyFlowController(LobbyState lobbyState, FlowListener listener) { + this.lobbyState = lobbyState; + this.listener = listener; + } + + /** + * Host: Listens for a joiner to enter the lobby. + * When status becomes "joined", calls onJoinerArrived(). + */ + public void listenForJoiner(String gameId) { + this.activeGameId = gameId; + lobbyState.listenForOpponentReady(gameId, + () -> Gdx.app.postRunnable(() -> { + if (listener != null) { + listener.onJoinerArrived(); + } + })); + } + + /** + * Host: Signals the game to start. + * Writes status = "started" to Firebase. + */ + public void signalGameStart(String gameId) { + lobbyState.startGame(gameId); + } + + /** + * Joiner: Listens for host to start the game. + * When status becomes "started", calls onGameStarted(). + */ + public void listenForGameStart(String gameId) { + this.activeGameId = gameId; + lobbyState.listenForGameStart(gameId, + () -> Gdx.app.postRunnable(() -> { + if (listener != null) { + listener.onGameStarted(); + } + })); + } + + public String getActiveGameId() { + return activeGameId; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java new file mode 100644 index 0000000..22e1f47 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java @@ -0,0 +1,200 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Slider; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; + +/** + * Builds and manages the host-specific UI for lobby creation. + * Contains board size slider, budget slider, create button, and start button. + */ +public class LobbyHostUI { + + public interface HostUIListener { + void onCreateLobby(int boardSize, int budget); + void onStartGame(); + void onBack(); + } + + private final Skin skin; + private final HostUIListener listener; + + private int boardSize = LobbyScreenConfig.BOARD_DEFAULT; + private int budget = LobbyScreenConfig.BUDGET_DEFAULT; + + private Label boardSizeValueLabel; + private Label budgetValueLabel; + private Label statusLabel; + private TextButton createBtn; + private TextButton startBtn; + private Table sliderSection; + + public LobbyHostUI(Skin skin, HostUIListener listener) { + this.skin = skin; + this.listener = listener; + } + + public Table build() { + Table container = new Table(); + container.top(); + + // Board size controls + Label boardSizeLabel = new Label("Board Size", skin); + boardSizeValueLabel = new Label(boardSize + " x " + boardSize, skin); + + Slider boardSlider = createBoardSlider(); + + // Budget controls + Label budgetLabel = new Label("Starting Budget", skin); + budgetValueLabel = new Label(String.valueOf(budget), skin); + + Slider budgetSlider = createBudgetSlider(); + + // Status label + statusLabel = new Label("", skin, "small"); + statusLabel.setAlignment(Align.center); + + // Buttons + createBtn = new TextButton("Create Lobby", skin, "accent"); + createBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!createBtn.isDisabled()) { + listener.onCreateLobby(boardSize, budget); + } + } + }); + + startBtn = new TextButton("Start Game", skin, "accent"); + startBtn.setVisible(false); + startBtn.setDisabled(true); + startBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!startBtn.isDisabled()) { + listener.onStartGame(); + } + } + }); + + // Sliders grouped so they can be hidden after lobby is created + sliderSection = new Table(); + sliderSection.add(buildRow(boardSizeLabel, boardSizeValueLabel)) + .expandX().fillX().padBottom(8).row(); + sliderSection.add(boardSlider) + .width(LobbyScreenConfig.SLIDER_WIDTH) + .height(LobbyScreenConfig.SLIDER_HEIGHT) + .padBottom(24).row(); + sliderSection.add(buildRow(budgetLabel, budgetValueLabel)) + .expandX().fillX().padBottom(8).row(); + sliderSection.add(budgetSlider) + .width(LobbyScreenConfig.SLIDER_WIDTH) + .height(LobbyScreenConfig.SLIDER_HEIGHT) + .padBottom(32).row(); + container.add(sliderSection).expandX().fillX().row(); + container.add(createBtn) + .width(LobbyScreenConfig.BUTTON_WIDTH) + .height(LobbyScreenConfig.BUTTON_HEIGHT) + .padBottom(8).row(); + container.add(startBtn) + .width(LobbyScreenConfig.BUTTON_WIDTH) + .height(LobbyScreenConfig.BUTTON_HEIGHT) + .padBottom(16).row(); + container.add(statusLabel).expandX().row(); + + return container; + } + + private Slider createBoardSlider() { + Slider slider = new Slider( + LobbyScreenConfig.BOARD_MIN, + LobbyScreenConfig.BOARD_MAX, + 1, + false, + buildSliderStyle()); + slider.setValue(LobbyScreenConfig.BOARD_DEFAULT); + slider.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + boardSize = (int) slider.getValue(); + boardSizeValueLabel.setText(boardSize + " x " + boardSize); + } + }); + return slider; + } + + private Slider createBudgetSlider() { + Slider slider = new Slider( + LobbyScreenConfig.BUDGET_MIN, + LobbyScreenConfig.BUDGET_MAX, + LobbyScreenConfig.BUDGET_STEP, + false, + buildSliderStyle()); + slider.setValue(LobbyScreenConfig.BUDGET_DEFAULT); + slider.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + budget = (int) slider.getValue(); + budgetValueLabel.setText(String.valueOf(budget)); + } + }); + return slider; + } + + private Slider.SliderStyle buildSliderStyle() { + Slider.SliderStyle style = new Slider.SliderStyle(); + style.background = skin.getDrawable("primary-pixel"); + style.knob = skin.getDrawable("accent-pixel"); + return style; + } + + private Table buildRow(Label left, Label right) { + Table row = new Table(); + row.add(left).expandX().left(); + row.add(right).expandX().right(); + return row; + } + + // Public methods for updating UI state + + public void showCreatingState() { + createBtn.setDisabled(true); + setStatus("Creating lobby..."); + } + + public void showLobbyCreated(String gameId) { + sliderSection.setVisible(false); + createBtn.setVisible(false); + startBtn.setVisible(true); + startBtn.setDisabled(true); + setStatus("Lobby created!\nGame ID: " + gameId + + "\n\nWaiting for opponent to join..."); + } + + public void showJoinerArrived() { + setStatus("Opponent joined! Press Start Game when you are ready."); + startBtn.setDisabled(false); + } + + public void showStartingState() { + startBtn.setDisabled(true); + setStatus("Starting..."); + } + + public void showCreationFailed() { + setStatus("Failed to create lobby. Try again."); + createBtn.setDisabled(false); + } + + public void setStatus(String message) { + if (statusLabel != null) { + statusLabel.setText(message); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java new file mode 100644 index 0000000..97b470a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java @@ -0,0 +1,117 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; + +/** + * Builds and manages the join-specific UI for joining an existing lobby. + * Contains game ID display, board/budget info, and join button. + */ +public class LobbyJoinUI { + + public interface JoinUIListener { + void onJoin(String gameId); + void onBack(); + } + + private final Skin skin; + private final JoinUIListener listener; + private final String gameId; + + private Label boardInfoLabel; + private Label budgetInfoLabel; + private Label statusLabel; + private TextButton joinBtn; + + private int boardSize = -1; + private int budget = -1; + + public LobbyJoinUI(Skin skin, String gameId, JoinUIListener listener) { + this.skin = skin; + this.gameId = gameId; + this.listener = listener; + } + + public Table build() { + Table container = new Table(); + container.top(); + + // Game ID display + Label gameIdLabel = new Label("Game ID: " + gameId, skin, "title"); + gameIdLabel.setAlignment(Align.center); + + // Board and budget info (initially fetching) + boardInfoLabel = new Label("Board: fetching...", skin, "small"); + budgetInfoLabel = new Label("Budget: fetching...", skin, "small"); + + // Status label + statusLabel = new Label("", skin, "small"); + statusLabel.setAlignment(Align.center); + + // Join button + joinBtn = new TextButton("Join Match", skin, "accent"); + joinBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!joinBtn.isDisabled()) { + listener.onJoin(gameId); + } + } + }); + + // Layout + container.add(gameIdLabel).expandX().padBottom(24).row(); + container.add(boardInfoLabel).expandX().padBottom(4).row(); + container.add(budgetInfoLabel).expandX().padBottom(32).row(); + container.add(joinBtn) + .width(LobbyScreenConfig.BUTTON_WIDTH) + .height(LobbyScreenConfig.BUTTON_HEIGHT) + .padBottom(16).row(); + container.add(statusLabel).expandX().row(); + + return container; + } + + // Public methods for updating UI state + + public void updateLobbyInfo(int boardSize, int budget) { + this.boardSize = boardSize; + this.budget = budget; + boardInfoLabel.setText("Board: " + boardSize + " x " + boardSize); + budgetInfoLabel.setText("Budget: " + budget); + } + + public void showJoiningState() { + joinBtn.setDisabled(true); + setStatus("Connecting..."); + } + + public void showJoinedState() { + setStatus("Joined!\nWaiting for host to start the game..."); + } + + public void showJoinFailed() { + setStatus("Lobby not found. Check the Game ID."); + joinBtn.setDisabled(false); + } + + public void setStatus(String message) { + if (statusLabel != null) { + statusLabel.setText(message); + } + } + + public int getBoardSize() { + return boardSize; + } + + public int getBudget() { + return budget; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java new file mode 100644 index 0000000..8b4a249 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java @@ -0,0 +1,10 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java +package com.group14.regicidechess.screens.lobby; + +/** + * Defines the two modes for the lobby screen. + */ +public enum LobbyMode { + HOST, // Player creating a new lobby + JOIN // Player joining an existing lobby +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java new file mode 100644 index 0000000..52dfb98 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java @@ -0,0 +1,301 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; +import com.group14.regicidechess.screens.setup.SetupScreen; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * LobbyScreen — thin coordinator for lobby creation and joining. + * + * Refactored version with improved modularity: + * - UI components extracted to LobbyHostUI and LobbyJoinUI + * - Firebase flow extracted to LobbyFlowController + * - State management extracted to LobbyStateManager + * - Screen now focuses purely on coordination between components + */ +public class LobbyScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + + // LibGDX + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // Mode and data + private final LobbyMode mode; + private final String incomingGameId; + + // Components + private final LobbyStateManager stateManager; + private final LobbyFlowController flowController; + private final LobbyHostUI hostUI; + private final LobbyJoinUI joinUI; + + // Shared UI + private Label titleLabel; + private Table root; + + public LobbyScreen(Game game, SpriteBatch batch, LobbyMode mode, Lobby lobby) { + this.game = game; + this.batch = batch; + this.mode = mode; + this.incomingGameId = lobby != null ? lobby.getGameId() : null; + + // Initialize components + this.stateManager = new LobbyStateManager(); + if (lobby != null) { + stateManager.setPrefetchedLobby(lobby); + } + + this.flowController = new LobbyFlowController(stateManager.getLobbyState(), createFlowListener()); + this.stateManager.setListener(createStateListener()); + + // LibGDX setup + this.stage = new Stage(new FitViewport( + LobbyScreenConfig.VIEWPORT_WIDTH, + LobbyScreenConfig.VIEWPORT_HEIGHT), + batch); + this.skin = ResourceManager.getInstance().getSkin(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + // Create mode-specific UI + if (mode == LobbyMode.HOST) { + this.hostUI = new LobbyHostUI(skin, createHostUIListener()); + this.joinUI = null; + } else { + this.joinUI = new LobbyJoinUI(skin, incomingGameId, createJoinUIListener()); + this.hostUI = null; + } + + stateManager.enter(); + buildUI(); + + // If we have prefetched lobby data for joiner, update UI immediately + if (mode == LobbyMode.JOIN && lobby != null) { + joinUI.updateLobbyInfo(lobby.getBoardSize(), lobby.getBudget()); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // UI construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildUI() { + root = new Table(); + root.setFillParent(true); + root.setBackground(skin.getDrawable("surface-pixel")); + root.top().pad(32); + stage.addActor(root); + + // Title + String titleText = mode == LobbyMode.HOST ? "Create Lobby" : "Join Lobby"; + titleLabel = new Label(titleText, skin, "title"); + titleLabel.setAlignment(com.badlogic.gdx.utils.Align.center); + root.add(titleLabel).expandX().padBottom(32).row(); + + // Mode-specific content + if (mode == LobbyMode.HOST) { + root.add(hostUI.build()).expandX().fillX().row(); + } else { + root.add(joinUI.build()).expandX().fillX().row(); + } + + // Back button (shared) + TextButton backBtn = new TextButton("Back", skin, "default"); + backBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onBack(); + } + }); + root.add(backBtn) + .width(LobbyScreenConfig.BACK_BUTTON_WIDTH) + .height(LobbyScreenConfig.BACK_BUTTON_HEIGHT) + .padTop(24).row(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Listeners + // ───────────────────────────────────────────────────────────────────────── + + private LobbyHostUI.HostUIListener createHostUIListener() { + return new LobbyHostUI.HostUIListener() { + @Override + public void onCreateLobby(int boardSize, int budget) { + hostUI.showCreatingState(); + stateManager.setBoardSize(boardSize); + stateManager.setBudget(budget); + stateManager.createLobby(); + } + + @Override + public void onStartGame() { + hostUI.showStartingState(); + String gameId = stateManager.getGameId(); + flowController.signalGameStart(gameId); + navigateToSetup(gameId, true); + } + + @Override + public void onBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + }; + } + + private LobbyJoinUI.JoinUIListener createJoinUIListener() { + return new LobbyJoinUI.JoinUIListener() { + @Override + public void onJoin(String gameId) { + joinUI.showJoiningState(); + stateManager.joinLobby(gameId); + } + + @Override + public void onBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + }; + } + + private LobbyStateManager.StateListener createStateListener() { + return new LobbyStateManager.StateListener() { + @Override + public void onLobbyCreated(String gameId) { + hostUI.showLobbyCreated(gameId); + flowController.listenForJoiner(gameId); + } + + @Override + public void onLobbyCreationFailed() { + hostUI.showCreationFailed(); + } + + @Override + public void onLobbyJoined() { + joinUI.updateLobbyInfo( + stateManager.getBoardSize(), + stateManager.getBudget()); + joinUI.showJoinedState(); + flowController.listenForGameStart(stateManager.getGameId()); + } + + @Override + public void onLobbyJoinFailed() { + joinUI.showJoinFailed(); + } + }; + } + + private LobbyFlowController.FlowListener createFlowListener() { + return new LobbyFlowController.FlowListener() { + @Override + public void onJoinerArrived() { + hostUI.showJoinerArrived(); + } + + @Override + public void onGameStarted() { + navigateToSetup(stateManager.getGameId(), false); + } + + @Override + public void onError(String message) { + if (mode == LobbyMode.HOST) { + hostUI.setStatus(message); + } else { + joinUI.setStatus(message); + } + } + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Navigation + // ───────────────────────────────────────────────────────────────────────── + + private void navigateToSetup(String gameId, boolean isHost) { + int boardSize = stateManager.getBoardSize(); + int budget = stateManager.getBudget(); + game.setScreen(new SetupScreen(game, batch, gameId, boardSize, budget, isHost)); + } + + private void onBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void render(float delta) { + Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + stateManager.update(delta); + stage.act(delta); + stage.draw(); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + inputHandler.clearObservers(); + stateManager.exit(); + } + + @Override + public void dispose() { + stage.dispose(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Input handling (unused but required by interface) + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int x, int y, int pointer, int button) {} + + @Override + public void onDrag(int x, int y, int pointer) {} + + @Override + public void onRelease(int x, int y, int pointer, int button) {} + + @Override + public void onKeyDown(int keycode) {} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java new file mode 100644 index 0000000..44e0d18 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java @@ -0,0 +1,32 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java +package com.group14.regicidechess.screens.lobby; + +/** + * Configuration constants for the lobby screen. + */ +public final class LobbyScreenConfig { + private LobbyScreenConfig() {} // Prevent instantiation + + // Viewport dimensions + public static final float VIEWPORT_WIDTH = 480f; + public static final float VIEWPORT_HEIGHT = 854f; + + // Board size limits + public static final int BOARD_MIN = 5; + public static final int BOARD_MAX = 8; + public static final int BOARD_DEFAULT = 8; + + // Budget limits + public static final int BUDGET_MIN = 5; + public static final int BUDGET_MAX = 50; + public static final int BUDGET_DEFAULT = 25; + public static final int BUDGET_STEP = 1; + + // UI dimensions + public static final int SLIDER_WIDTH = 380; + public static final int SLIDER_HEIGHT = 40; + public static final int BUTTON_WIDTH = 280; + public static final int BUTTON_HEIGHT = 60; + public static final int BACK_BUTTON_WIDTH = 200; + public static final int BACK_BUTTON_HEIGHT = 50; +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java new file mode 100644 index 0000000..ac37d51 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java @@ -0,0 +1,123 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java +package com.group14.regicidechess.screens.lobby; + +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.states.LobbyState; + +/** + * Manages lobby state and provides a clean interface for state operations. + * Encapsulates LobbyState and provides methods for host and join operations. + */ +public class LobbyStateManager { + + public interface StateListener { + void onLobbyCreated(String gameId); + void onLobbyCreationFailed(); + void onLobbyJoined(); + void onLobbyJoinFailed(); + } + + private final LobbyState lobbyState; + private StateListener listener; + + private int boardSize; + private int budget; + private String gameId; + + public LobbyStateManager() { + this.lobbyState = new LobbyState(); + this.boardSize = LobbyScreenConfig.BOARD_DEFAULT; + this.budget = LobbyScreenConfig.BUDGET_DEFAULT; + } + + public void setListener(StateListener listener) { + this.listener = listener; + } + + public void enter() { + lobbyState.enter(); + } + + public void exit() { + lobbyState.exit(); + } + + public void update(float delta) { + lobbyState.update(delta); + } + + // Host methods + + public void setBoardSize(int boardSize) { + this.boardSize = boardSize; + } + + public void setBudget(int budget) { + this.budget = budget; + } + + public void createLobby() { + lobbyState.createLobby(boardSize, budget, + () -> { + if (listener != null) { + listener.onLobbyCreated(lobbyState.getLobby().getGameId()); + } + }, + () -> { + if (listener != null) { + listener.onLobbyCreationFailed(); + } + }); + } + + // Join methods + + public void setPrefetchedLobby(Lobby lobby) { + if (lobby != null) { + lobbyState.setPrefetchedLobby(lobby); + this.boardSize = lobby.getBoardSize(); + this.budget = lobby.getBudget(); + this.gameId = lobby.getGameId(); + } + } + + public void joinLobby(String gameId) { + this.gameId = gameId; + lobbyState.joinLobby(gameId, + () -> { + if (listener != null) { + listener.onLobbyJoined(); + } + }, + () -> { + if (listener != null) { + listener.onLobbyJoinFailed(); + } + }); + } + + // Getters + + public Lobby getLobby() { + return lobbyState.getLobby(); + } + + public int getBoardSize() { + return lobbyState.getLobby() != null ? + lobbyState.getLobby().getBoardSize() : boardSize; + } + + public int getBudget() { + return lobbyState.getLobby() != null ? + lobbyState.getLobby().getBudget() : budget; + } + + public String getGameId() { + return lobbyState.getLobby() != null ? + lobbyState.getLobby().getGameId() : gameId; + } + + public LobbyState getLobbyState() { + return lobbyState; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinLobbyScreen.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinLobbyScreen.java new file mode 100644 index 0000000..25837e3 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinLobbyScreen.java @@ -0,0 +1,205 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinLobbyScreen.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.ui.TextField; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.screens.lobby.LobbyMode; +import com.group14.regicidechess.screens.lobby.LobbyScreen; +import com.group14.regicidechess.states.LobbyState; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * JoinLobbyScreen — dedicated screen for entering a Game ID and joining a lobby. + * Replaces the inline join panel that previously appeared in the main menu. + */ +public class JoinLobbyScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + private final LobbyState lobbyState; + private final LobbyValidator validator; + + private TextField gameIdField; + private TextButton joinBtn; + private Label statusLabel; + + public JoinLobbyScreen(Game game, SpriteBatch batch) { + this.game = game; + this.batch = batch; + this.lobbyState = new LobbyState(); + + this.stage = new Stage(new FitViewport( + MainMenuConfig.VIEWPORT_WIDTH, + MainMenuConfig.VIEWPORT_HEIGHT), + batch); + this.skin = ResourceManager.getInstance().getSkin(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + this.validator = new LobbyValidator(lobbyState, createValidationListener()); + + buildUI(); + } + + private void buildUI() { + Table root = new Table(); + root.setFillParent(true); + root.setBackground(skin.getDrawable("surface-pixel")); + root.top().pad(32); + + Label titleLabel = new Label("Join Lobby", skin, "title"); + titleLabel.setAlignment(Align.center); + + Label hint = new Label("Enter Game ID", skin, "small"); + + gameIdField = new TextField("", skin); + gameIdField.setMessageText("e.g. ABC123"); + gameIdField.setMaxLength(MainMenuConfig.MAX_GAME_ID_LENGTH); + + joinBtn = new TextButton("Join", skin, "accent"); + + statusLabel = new Label("", skin, "small"); + statusLabel.setColor(Color.RED); + statusLabel.setAlignment(Align.center); + + TextButton backBtn = new TextButton("Back", skin, "default"); + + root.add(titleLabel).expandX().padBottom(48).row(); + root.add(hint).expandX().padBottom(12).row(); + root.add(gameIdField) + .width(MainMenuConfig.GAME_ID_FIELD_WIDTH) + .height(MainMenuConfig.GAME_ID_FIELD_HEIGHT) + .padBottom(16).row(); + root.add(joinBtn) + .width(MainMenuConfig.JOIN_BTN_WIDTH) + .height(MainMenuConfig.JOIN_BTN_HEIGHT) + .padBottom(12).row(); + root.add(statusLabel).expandX().padBottom(16).row(); + root.add(backBtn) + .width(MainMenuConfig.BACK_BTN_WIDTH) + .height(MainMenuConfig.BACK_BTN_HEIGHT) + .row(); + + joinBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onJoinPressed(); + } + }); + + backBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + game.setScreen(new MainMenuScreen(game, batch)); + } + }); + + stage.addActor(root); + } + + private void onJoinPressed() { + String gameId = gameIdField.getText().trim().toUpperCase(); + if (gameId.isEmpty()) { + statusLabel.setText("Please enter a Game ID."); + return; + } + joinBtn.setDisabled(true); + statusLabel.setColor(Color.WHITE); + statusLabel.setText("Checking..."); + validator.validate(gameId); + } + + private LobbyValidator.ValidationListener createValidationListener() { + return new LobbyValidator.ValidationListener() { + @Override + public void onLobbyFound(Lobby lobby) { + game.setScreen(new LobbyScreen(game, batch, LobbyMode.JOIN, lobby)); + } + + @Override + public void onLobbyNotFound() { + joinBtn.setDisabled(false); + statusLabel.setColor(Color.RED); + statusLabel.setText("Lobby not found. Check the Game ID."); + } + + @Override + public void onValidationError(String message) { + joinBtn.setDisabled(false); + statusLabel.setColor(Color.RED); + statusLabel.setText(message); + } + }; + } + + @Override + public void show() { + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void render(float delta) { + Gdx.gl.glClearColor( + MainMenuConfig.BG_R, + MainMenuConfig.BG_G, + MainMenuConfig.BG_B, + MainMenuConfig.BG_A); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + stage.act(delta); + stage.draw(); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + inputHandler.clearObservers(); + validator.cancel(); + } + + @Override + public void dispose() { + stage.dispose(); + } + @Override + public void onTap(int x, int y, int pointer, int button) {} + + @Override + public void onDrag(int x, int y, int pointer) {} + + @Override + public void onRelease(int x, int y, int pointer, int button) {} + + @Override + public void onKeyDown(int keycode) {} +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java new file mode 100644 index 0000000..14177d8 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java @@ -0,0 +1,78 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.Gdx; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.states.LobbyState; + +/** + * Validates lobby existence in Firebase before navigating. + * Handles async validation and provides callbacks for success/failure. + */ +public class LobbyValidator { + + public interface ValidationListener { + void onLobbyFound(Lobby lobby); + void onLobbyNotFound(); + void onValidationError(String message); + } + + private final LobbyState lobbyState; + private final ValidationListener listener; + private boolean isValidating = false; + + public LobbyValidator(LobbyState lobbyState, ValidationListener listener) { + this.lobbyState = lobbyState; + this.listener = listener; + } + + /** + * Validates a Game ID by fetching it from Firebase. + * + * @param gameId the Game ID to validate + */ + public void validate(String gameId) { + if (isValidating) { + if (listener != null) { + listener.onValidationError("Already checking..."); + } + return; + } + + String trimmedId = gameId != null ? gameId.trim() : ""; + + if (trimmedId.isEmpty()) { + if (listener != null) { + listener.onValidationError("Please enter a Game ID."); + } + return; + } + + isValidating = true; + + lobbyState.validateLobby(trimmedId, + // Lobby found + lobby -> Gdx.app.postRunnable(() -> { + isValidating = false; + if (listener != null) { + listener.onLobbyFound(lobby); + } + }), + // Lobby not found + error -> Gdx.app.postRunnable(() -> { + isValidating = false; + if (listener != null) { + listener.onLobbyNotFound(); + } + }) + ); + } + + public boolean isValidating() { + return isValidating; + } + + public void cancel() { + isValidating = false; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java new file mode 100644 index 0000000..b755beb --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java @@ -0,0 +1,39 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java +package com.group14.regicidechess.screens.mainmenu; + +/** + * Configuration constants for the main menu screen. + */ +public final class MainMenuConfig { + private MainMenuConfig() {} // Prevent instantiation + + // Viewport dimensions + public static final float VIEWPORT_WIDTH = 480f; + public static final float VIEWPORT_HEIGHT = 854f; + + // Layout dimensions + public static final int TITLE_PAD_TOP = 120; + public static final int TITLE_PAD_BOTTOM = 8; + public static final int SUBTITLE_PAD_BOTTOM = 80; + public static final int BUTTON_WIDTH = 300; + public static final int BUTTON_HEIGHT = 60; + public static final int BUTTON_PAD_BOTTOM = 20; + public static final int JOIN_BUTTON_PAD_BOTTOM = 16; + public static final int JOIN_PANEL_WIDTH = 320; + + // Join panel specific + public static final int GAME_ID_FIELD_WIDTH = 200; + public static final int GAME_ID_FIELD_HEIGHT = 50; + public static final int JOIN_BTN_WIDTH = 80; + public static final int JOIN_BTN_HEIGHT = 50; + public static final int BACK_BTN_WIDTH = 290; + public static final int BACK_BTN_HEIGHT = 48; + public static final int JOIN_PANEL_PAD = 16; + public static final int MAX_GAME_ID_LENGTH = 10; + + // Colors + public static final float BG_R = 0.12f; + public static final float BG_G = 0.12f; + public static final float BG_B = 0.15f; + public static final float BG_A = 1f; +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java new file mode 100644 index 0000000..375b3b4 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java @@ -0,0 +1,130 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.screens.lobby.LobbyMode; +import com.group14.regicidechess.screens.lobby.LobbyScreen; +import com.group14.regicidechess.states.MainMenuState; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * MainMenuScreen — thin coordinator for the main menu. + * Navigates to LobbyScreen (HOST) or JoinLobbyScreen depending on user action. + */ +public class MainMenuScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + + // LibGDX + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // State and UI + private final MainMenuState mainMenuState; + private final MainMenuUI mainMenuUI; + + public MainMenuScreen(Game game, SpriteBatch batch) { + this.game = game; + this.batch = batch; + + this.mainMenuState = new MainMenuState(); + mainMenuState.enter(); + + this.stage = new Stage(new FitViewport( + MainMenuConfig.VIEWPORT_WIDTH, + MainMenuConfig.VIEWPORT_HEIGHT), + batch); + this.skin = ResourceManager.getInstance().getSkin(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + this.mainMenuUI = new MainMenuUI(skin, createUIListener()); + stage.addActor(mainMenuUI.build()); + } + + private MainMenuUI.MainMenuUIListener createUIListener() { + return new MainMenuUI.MainMenuUIListener() { + @Override + public void onCreateLobby() { + game.setScreen(new LobbyScreen(game, batch, LobbyMode.HOST, null)); + } + + @Override + public void onJoinLobbyScreen() { + game.setScreen(new JoinLobbyScreen(game, batch)); + } + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void render(float delta) { + Gdx.gl.glClearColor( + MainMenuConfig.BG_R, + MainMenuConfig.BG_G, + MainMenuConfig.BG_B, + MainMenuConfig.BG_A); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + mainMenuState.update(delta); + + stage.act(delta); + stage.draw(); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + inputHandler.clearObservers(); + mainMenuState.exit(); + } + + @Override + public void dispose() { + stage.dispose(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Input handling (unused but required by interface) + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int x, int y, int pointer, int button) {} + + @Override + public void onDrag(int x, int y, int pointer) {} + + @Override + public void onRelease(int x, int y, int pointer, int button) {} + + @Override + public void onKeyDown(int keycode) {} +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java new file mode 100644 index 0000000..0811a7a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java @@ -0,0 +1,113 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; + +/** + * Builds and manages the main menu UI. + * Contains title, subtitle, and navigation buttons. + */ +public class MainMenuUI { + + public interface MainMenuUIListener { + void onCreateLobby(); + void onJoinLobbyScreen(); + } + + private final Skin skin; + private final MainMenuUIListener listener; + + private Label mainErrorLabel; + + public MainMenuUI(Skin skin, MainMenuUIListener listener) { + this.skin = skin; + this.listener = listener; + } + + public Table build() { + Table root = new Table(); + root.setFillParent(true); + root.setBackground(skin.getDrawable("surface-pixel")); + + // Title + Label titleLabel = new Label("REGICIDE\nCHESS", skin, "title"); + titleLabel.setAlignment(Align.center); + + // Subtitle + Label subtitleLabel = new Label("online strategy chess", skin, "small"); + subtitleLabel.setAlignment(Align.center); + + // Buttons + TextButton createBtn = new TextButton("Create Lobby", skin, "accent"); + TextButton joinBtn = new TextButton("Join Lobby", skin, "default"); + createBtn.pad(12); + joinBtn.pad(12); + + // Error label + mainErrorLabel = new Label("", skin, "small"); + mainErrorLabel.setColor(com.badlogic.gdx.graphics.Color.RED); + mainErrorLabel.setAlignment(Align.center); + + // Layout + root.add(titleLabel) + .expandX() + .padTop(MainMenuConfig.TITLE_PAD_TOP) + .padBottom(MainMenuConfig.TITLE_PAD_BOTTOM) + .row(); + root.add(subtitleLabel) + .expandX() + .padBottom(MainMenuConfig.SUBTITLE_PAD_BOTTOM) + .row(); + root.add(createBtn) + .width(MainMenuConfig.BUTTON_WIDTH) + .height(MainMenuConfig.BUTTON_HEIGHT) + .padBottom(MainMenuConfig.BUTTON_PAD_BOTTOM) + .row(); + root.add(joinBtn) + .width(MainMenuConfig.BUTTON_WIDTH) + .height(MainMenuConfig.BUTTON_HEIGHT) + .padBottom(MainMenuConfig.JOIN_BUTTON_PAD_BOTTOM) + .row(); + root.add(mainErrorLabel) + .expandX() + .padBottom(8) + .row(); + + // Button listeners + createBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + clearError(); + if (listener != null) { + listener.onCreateLobby(); + } + } + }); + + joinBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + clearError(); + if (listener != null) { + listener.onJoinLobbyScreen(); + } + } + }); + + return root; + } + + public void showError(String message) { + mainErrorLabel.setText(message); + } + + public void clearError() { + mainErrorLabel.setText(""); + } +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java new file mode 100644 index 0000000..e2cbb8a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java @@ -0,0 +1,43 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java +package com.group14.regicidechess.screens.setup; + +import com.group14.regicidechess.model.Player; + +/** + * Handles conversion between logic coordinates and display coordinates. + * White: display = logic + * Black: display = (size - 1 - logic) + */ +public class BoardCoordinateMapper { + private final Player localPlayer; + private final int boardSize; + + public BoardCoordinateMapper(Player localPlayer, int boardSize) { + this.localPlayer = localPlayer; + this.boardSize = boardSize; + } + + public int toDisplayRow(int logicRow) { + return localPlayer.isWhite() ? logicRow : (boardSize - 1 - logicRow); + } + + public int toLogicRow(int displayRow) { + return localPlayer.isWhite() ? displayRow : (boardSize - 1 - displayRow); + } + + public int getHomeDisplayRowMin(int homeRowMin) { + return toDisplayRow(homeRowMin); + } + + public int getHomeDisplayRowMax(int homeRowMax) { + return toDisplayRow(homeRowMax); + } + + public Player getLocalPlayer() { + return localPlayer; + } + + public int getBoardSize() { + return boardSize; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java new file mode 100644 index 0000000..2fa38be --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java @@ -0,0 +1,45 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * Encapsulates retry logic for fetching opponent's board. + */ +public class BoardFetchRetryPolicy { + private final int maxRetries; + private final long retryDelayMs; + + public BoardFetchRetryPolicy() { + this(SetupScreenConfig.BOARD_FETCH_MAX_RETRIES, SetupScreenConfig.BOARD_FETCH_RETRY_MS); + } + + public BoardFetchRetryPolicy(int maxRetries, long retryDelayMs) { + this.maxRetries = maxRetries; + this.retryDelayMs = retryDelayMs; + } + + public boolean shouldRetry(int currentRetryCount) { + return currentRetryCount < maxRetries; + } + + public void scheduleRetry(Runnable retryAction, int retryNumber) { + new Timer().schedule(new TimerTask() { + @Override + public void run() { + Gdx.app.postRunnable(retryAction); + } + }, retryDelayMs); + } + + public int getMaxRetries() { + return maxRetries; + } + + public long getRetryDelayMs() { + return retryDelayMs; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java new file mode 100644 index 0000000..2b54411 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java @@ -0,0 +1,73 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java +package com.group14.regicidechess.screens.setup; + +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; + +/** + * SetupBoardCodec — converts between a Board's pieces and the int[][] format + * used by Firebase. + */ +public final class SetupBoardCodec { + + public static final int CODE_KING = 1; + public static final int CODE_QUEEN = 2; + public static final int CODE_ROOK = 3; + public static final int CODE_BISHOP = 4; + public static final int CODE_KNIGHT = 5; + public static final int CODE_PAWN = 6; + + private SetupBoardCodec() {} + + public static int[][] encode(Board board) { + int size = board.getSize(); + int[][] state = new int[size][size]; + for (ChessPiece piece : board.getPieces()) { + int col = (int) piece.getPosition().x; + int row = (int) piece.getPosition().y; + state[col][row] = toCode(piece); + } + return state; + } + + public static void mergeInto(int[][] encoded, Player owner, Board board) { + if (encoded == null || encoded.length == 0) return; + for (int col = 0; col < encoded.length; col++) { + for (int row = 0; row < encoded[col].length; row++) { + int code = encoded[col][row]; + if (code == 0) continue; + ChessPiece piece = decode(code, owner); + if (piece != null) board.placePiece(piece, col, row); + } + } + } + + public static int toCode(ChessPiece piece) { + if (piece instanceof King) return CODE_KING; + if (piece instanceof Queen) return CODE_QUEEN; + if (piece instanceof Rook) return CODE_ROOK; + if (piece instanceof Bishop) return CODE_BISHOP; + if (piece instanceof Knight) return CODE_KNIGHT; + if (piece instanceof Pawn) return CODE_PAWN; + return 0; + } + + public static ChessPiece decode(int code, Player owner) { + switch (code) { + case CODE_KING: return new King(owner); + case CODE_QUEEN: return new Queen(owner); + case CODE_ROOK: return new Rook(owner); + case CODE_BISHOP: return new Bishop(owner); + case CODE_KNIGHT: return new Knight(owner); + case CODE_PAWN: return new Pawn(owner); + default: return null; + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java new file mode 100644 index 0000000..c6aa1a6 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java @@ -0,0 +1,144 @@ +// SetupBoardInputHandler.java - Updated version +package com.group14.regicidechess.screens.setup; + +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.states.SetupState; + +/** + * Handles board tap input for the setup screen. + */ +public class SetupBoardInputHandler { + + public interface BoardActionListener { + void onPiecePlaced(ChessPiece piece, int col, int row); + void onPieceRemoved(int col, int row); + void onPieceReplaced(ChessPiece oldPiece, ChessPiece newPiece, int col, int row); + void onInvalidPlacement(String reason); + void onStateChanged(); + } + + private final SetupState setupState; + private final SetupBoardRenderer renderer; + private final SetupPaletteWidget palette; + private final Player localPlayer; + private final BoardActionListener listener; + private final boolean isLocked; + + public SetupBoardInputHandler(SetupState setupState, SetupBoardRenderer renderer, + SetupPaletteWidget palette, Player localPlayer, + BoardActionListener listener) { + this(setupState, renderer, palette, localPlayer, listener, false); + } + + public SetupBoardInputHandler(SetupState setupState, SetupBoardRenderer renderer, + SetupPaletteWidget palette, Player localPlayer, + BoardActionListener listener, boolean isLocked) { + this.setupState = setupState; + this.renderer = renderer; + this.palette = palette; + this.localPlayer = localPlayer; + this.listener = listener; + this.isLocked = isLocked; + } + + public boolean handleTap(float worldX, float worldY) { + // If locked, ignore all board interactions + if (isLocked) { + listener.onInvalidPlacement("Setup is locked. Unconfirm to make changes."); + return false; + } + + float cellSize = renderer.getCellSize(); + float boardLeft = renderer.getBoardLeft(); + float boardBottom = renderer.getBoardBottom(); + int size = setupState.getBoardSize(); + + if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return false; + if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return false; + + int col = Math.max(0, Math.min((int) ((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int) ((worldY - boardBottom) / cellSize), size - 1)); + int row = renderer.getCoordinateMapper().toLogicRow(dispRow); + + ChessPiece existing = setupState.getBoard().getPieceAt(col, row); + + if (palette.hasSelection()) { + ChessPiece newPiece = palette.createSelectedPiece(localPlayer); + + if (existing != null) { + // Check if it's the same piece type + boolean isSameType = existing.getClass().equals(newPiece.getClass()); + + if (isSameType) { + // Same piece type = remove + setupState.removePiece(col, row); + listener.onPieceRemoved(col, row); + // Keep selection for future placements + listener.onStateChanged(); + } else { + // Different piece type = replace + // Check if we can afford the new piece after refunding the old one + int costDiff = newPiece.getPointCost() - existing.getPointCost(); + + if (costDiff <= setupState.getPlayer().getBudgetRemaining()) { + // First remove old piece (this refunds its cost) + setupState.removePiece(col, row); + // Then place new piece (this spends its cost) + boolean success = setupState.placePiece(newPiece, col, row); + + if (success) { + listener.onPieceReplaced(existing, newPiece, col, row); + palette.afterPiecePlaced(); + listener.onStateChanged(); + } else { + // If placement fails, put the old piece back + setupState.placePiece(existing, col, row); + listener.onInvalidPlacement("Cannot replace piece - check budget and placement rules."); + } + } else { + listener.onInvalidPlacement("Not enough budget to replace with " + newPiece.getTypeName() + + " (needs " + costDiff + " more)"); + } + } + } else { + // Empty square - normal placement + boolean success = setupState.placePiece(newPiece, col, row); + + if (success) { + listener.onPiecePlaced(newPiece, col, row); + palette.afterPiecePlaced(); + listener.onStateChanged(); + } else { + String reason = getPlacementFailureReason(newPiece); + listener.onInvalidPlacement(reason); + } + } + } else if (existing != null) { + // No piece selected, clicking on existing piece = remove + setupState.removePiece(col, row); + listener.onPieceRemoved(col, row); + listener.onStateChanged(); + } + + return true; + } + + private String getPlacementFailureReason(ChessPiece piece) { + if (piece instanceof King && kingIsOnBoard()) { + return "You can only place one King!"; + } + return "Cannot place here — check home zone and budget."; + } + + private boolean kingIsOnBoard() { + for (ChessPiece p : setupState.getBoard().getPieces()) { + if (p instanceof King) return true; + } + return false; + } + + public void setLocked(boolean locked) { + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java new file mode 100644 index 0000000..787ab24 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java @@ -0,0 +1,150 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Matrix4; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.states.SetupState; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * SetupBoardRenderer — draws the setup-phase board and palette piece sprites. + */ +public class SetupBoardRenderer { + + private static final Color HOME_LIGHT = new Color(0.93f, 0.90f, 0.75f, 1f); + private static final Color HOME_DARK = new Color(0.45f, 0.62f, 0.32f, 1f); + private static final Color PLAIN_LIGHT = new Color(0.93f, 0.85f, 0.72f, 1f); + private static final Color PLAIN_DARK = new Color(0.55f, 0.38f, 0.24f, 1f); + private static final Color HOME_BORDER = new Color(0.4f, 0.85f, 0.4f, 0.9f); + private static final Color GRID_COLOR = new Color(0f, 0f, 0f, 0.3f); + + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final BoardCoordinateMapper coordinateMapper; + + private float boardLeft; + private float boardBottom; + private float cellSize; + + public SetupBoardRenderer(SpriteBatch batch, BoardCoordinateMapper coordinateMapper) { + this.batch = batch; + this.coordinateMapper = coordinateMapper; + this.shapeRenderer = new ShapeRenderer(); + } + + public void computeGeometry(float screenW, float screenH, + float headerH, float paletteH, float footerH, + int boardSize) { + float available = screenH - headerH - paletteH - footerH - 16f; + float maxW = screenW - 16f; + cellSize = Math.min(maxW / boardSize, available / boardSize); + boardLeft = (screenW - cellSize * boardSize) / 2f; + boardBottom = footerH + paletteH + (available - cellSize * boardSize) / 2f + 8f; + } + + public void drawBoard(Matrix4 projMatrix, SetupState state) { + int size = coordinateMapper.getBoardSize(); + shapeRenderer.setProjectionMatrix(projMatrix); + + drawTiles(state, size); + drawGrid(size); + drawHomeZoneBorder(state, size); + drawPlacedPieces(projMatrix, state, size); + } + + public void drawPaletteSprites(Matrix4 projMatrix, TextButton[] paletteButtons, String[] pieceNames) { + String color = coordinateMapper.getLocalPlayer().isWhite() ? "white" : "black"; + batch.setProjectionMatrix(projMatrix); + batch.begin(); + for (int i = 0; i < paletteButtons.length; i++) { + TextButton btn = paletteButtons[i]; + Texture tex = ResourceManager.getInstance().getPieceTexture(color, pieceNames[i].toLowerCase()); + float btnW = btn.getWidth(); + float btnH = btn.getHeight(); + Vector2 sp = btn.localToStageCoordinates(new Vector2(0, 0)); + float s = Math.min(btnW, btnH) * 0.42f; + batch.draw(tex, sp.x + (btnW - s) / 2f, sp.y + btnH - s - 4f, s, s); + } + batch.end(); + } + + private void drawTiles(SetupState state, int size) { + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + for (int col = 0; col < size; col++) { + for (int logicRow = 0; logicRow < size; logicRow++) { + int dispRow = coordinateMapper.toDisplayRow(logicRow); + float x = boardLeft + col * cellSize; + float y = boardBottom + dispRow * cellSize; + boolean light = (col + logicRow) % 2 == 0; + boolean home = logicRow >= state.getHomeRowMin() && logicRow <= state.getHomeRowMax(); + + if (home) { + shapeRenderer.setColor(light ? HOME_LIGHT : HOME_DARK); + } else { + shapeRenderer.setColor(light ? PLAIN_LIGHT : PLAIN_DARK); + } + shapeRenderer.rect(x, y, cellSize, cellSize); + } + } + shapeRenderer.end(); + } + + private void drawGrid(int size) { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(GRID_COLOR); + for (int i = 0; i <= size; i++) { + float x = boardLeft + i * cellSize; + float y = boardBottom + i * cellSize; + shapeRenderer.line(x, boardBottom, x, boardBottom + size * cellSize); + shapeRenderer.line(boardLeft, y, boardLeft + size * cellSize, y); + } + shapeRenderer.end(); + } + + private void drawHomeZoneBorder(SetupState state, int size) { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(HOME_BORDER); + Gdx.gl.glLineWidth(3f); + int homeDispMin = coordinateMapper.toDisplayRow(state.getHomeRowMin()); + int homeDispMax = coordinateMapper.toDisplayRow(state.getHomeRowMax()); + float zoneBottom = boardBottom + Math.min(homeDispMin, homeDispMax) * cellSize; + float zoneTop = boardBottom + (Math.max(homeDispMin, homeDispMax) + 1) * cellSize; + shapeRenderer.rect(boardLeft, zoneBottom, size * cellSize, zoneTop - zoneBottom); + Gdx.gl.glLineWidth(1f); + shapeRenderer.end(); + } + + private void drawPlacedPieces(Matrix4 projMatrix, SetupState state, int size) { + String color = coordinateMapper.getLocalPlayer().isWhite() ? "white" : "black"; + batch.setProjectionMatrix(projMatrix); + batch.begin(); + for (ChessPiece piece : state.getBoard().getPieces()) { + int col = (int) piece.getPosition().x; + int dispRow = coordinateMapper.toDisplayRow((int) piece.getPosition().y); + Texture tex = ResourceManager.getInstance().getPieceTexture(color, piece.getTypeName().toLowerCase()); + float pieceSize = cellSize * 0.8f; + float offset = (cellSize - pieceSize) / 2f; + batch.draw(tex, + boardLeft + col * cellSize + offset, + boardBottom + dispRow * cellSize + offset, + pieceSize, pieceSize); + } + batch.end(); + } + + public float getCellSize() { return cellSize; } + public float getBoardLeft() { return boardLeft; } + public float getBoardBottom() { return boardBottom; } + public BoardCoordinateMapper getCoordinateMapper() { return coordinateMapper; } + + public void dispose() { + shapeRenderer.dispose(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java new file mode 100644 index 0000000..dcdc9b6 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java @@ -0,0 +1,146 @@ +// SetupFlowController.java - Updated version +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.states.SetupState; + +/** + * Manages the 3-step Firebase flow for setup confirmation. + */ +public class SetupFlowController { + + public interface FlowListener { + void onUploadComplete(); + void onBothReady(); + void onOpponentBoardFetched(Board finalBoard, Player opponent); + void onError(String message); + void onUnconfirmSuccess(); + void onUnconfirmError(String message); + } + + private final String gameId; + private final Player localPlayer; + private final SetupState setupState; + private final FlowListener listener; + private final BoardFetchRetryPolicy retryPolicy; + + private int boardFetchRetries = 0; + private boolean isConfirmed = false; + + public SetupFlowController(String gameId, Player localPlayer, SetupState setupState, FlowListener listener) { + this(gameId, localPlayer, setupState, listener, new BoardFetchRetryPolicy()); + } + + public SetupFlowController(String gameId, Player localPlayer, SetupState setupState, + FlowListener listener, BoardFetchRetryPolicy retryPolicy) { + this.gameId = gameId; + this.localPlayer = localPlayer; + this.setupState = setupState; + this.listener = listener; + this.retryPolicy = retryPolicy; + } + + public void confirm() { + if (isConfirmed) { + listener.onError("Setup already confirmed!"); + return; + } + + // Check UI readiness (King placed) + if (!setupState.isReadyForConfirm()) { + listener.onError("Place your King before confirming."); + return; + } + + // Now set the player as ready for Firebase + if (!setupState.setReady()) { + listener.onError("Failed to set ready state."); + return; + } + + isConfirmed = true; + listener.onUploadComplete(); + + int[][] encoded = SetupBoardCodec.encode(setupState.getBoard()); + setupState.confirmSetup( + gameId, + localPlayer.isWhite(), + encoded, + this::listenForBothReady + ); + } + + public void unconfirm() { + if (!isConfirmed) { + listener.onError("Setup not confirmed yet!"); + return; + } + + // Reset the player's ready flag + setupState.getPlayer().resetReady(); + isConfirmed = false; + + setupState.unconfirmSetup( + gameId, + localPlayer.isWhite(), + () -> Gdx.app.postRunnable(() -> { + listener.onUnconfirmSuccess(); + }), + error -> Gdx.app.postRunnable(() -> { + listener.onUnconfirmError(error); + // Revert flag if unconfirm fails + isConfirmed = true; + setupState.setReady(); + }) + ); + } + + private void listenForBothReady() { + setupState.listenForBothSetupReady( + gameId, + () -> Gdx.app.postRunnable(() -> { + listener.onBothReady(); + fetchOpponentBoardWithRetry(); + }) + ); + } + + private void fetchOpponentBoardWithRetry() { + fetchOpponentBoard(); + } + + private void fetchOpponentBoard() { + setupState.getOpponentBoard( + gameId, + localPlayer.isWhite(), + opponentBoard -> Gdx.app.postRunnable(() -> { + boolean empty = opponentBoard == null || opponentBoard.length == 0; + + if (empty && retryPolicy.shouldRetry(boardFetchRetries)) { + boardFetchRetries++; + retryPolicy.scheduleRetry(this::fetchOpponentBoard, boardFetchRetries); + return; + } + + if (empty) { + listener.onError("Failed to fetch opponent's board after multiple attempts."); + return; + } + + Player opponentPlayer = new Player( + localPlayer.isWhite() ? "player2" : "player1", + !localPlayer.isWhite(), + localPlayer.getBudget()); + SetupBoardCodec.mergeInto(opponentBoard, opponentPlayer, setupState.getBoard()); + + listener.onOpponentBoardFetched(setupState.getBoard(), opponentPlayer); + }) + ); + } + + public boolean isConfirmed() { + return isConfirmed; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java new file mode 100644 index 0000000..f106234 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java @@ -0,0 +1,134 @@ +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; + +/** + * SetupFooterWidget — three visual states: + * + * SETUP: [Clear] | "Place King..." or "Ready!" | [Confirm] + * WAITING: [Unconfirm] | "Waiting for opponent..." | (empty) + * LOCKED: same as WAITING but via setWaitingMode(true) + */ +public class SetupFooterWidget { + + public interface FooterListener { + void onClear(); + void onConfirm(); + void onUnconfirm(); + } + + private final Skin skin; + private final FooterListener listener; + + private TextButton clearBtn; + private TextButton confirmBtn; + private TextButton unconfirmBtn; + private Label statusLabel; + private Label waitingLabel; + private Table footer; + + public SetupFooterWidget(Skin skin, FooterListener listener) { + this.skin = skin; + this.listener = listener; + } + + public Table build() { + footer = new Table(); + footer.setBackground(skin.getDrawable("surface-pixel")); + footer.pad(10); + + clearBtn = new TextButton("Clear", skin, "danger"); + confirmBtn = new TextButton("Confirm", skin, "accent"); + unconfirmBtn = new TextButton("Unconfirm", skin, "default"); + confirmBtn.setDisabled(true); + + statusLabel = new Label("Place your King to continue", skin, "small"); + statusLabel.setAlignment(Align.center); + statusLabel.setWrap(false); // prevent vertical expansion + + waitingLabel = new Label("Waiting for opponent...", skin, "small"); + waitingLabel.setAlignment(Align.center); + waitingLabel.setWrap(false); + + clearBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + listener.onClear(); + } + }); + confirmBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + if (!confirmBtn.isDisabled()) listener.onConfirm(); + } + }); + unconfirmBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + listener.onUnconfirm(); + } + }); + + showSetupMode(); // build initial layout + return footer; + } + + // ── Mode transitions ────────────────────────────────────────────────────── + + /** + * Normal editing state: + * [Clear] | status text | [Confirm] + */ + public void showSetupMode() { + if (footer == null) return; + footer.clearChildren(); + footer.add(clearBtn).width(110).height(48).left(); + footer.add(statusLabel).expandX().center().padLeft(8).padRight(8); + footer.add(confirmBtn).width(110).height(48).right(); + footer.layout(); + } + + /** + * Waiting / confirmed state: + * [Unconfirm] | "Waiting for opponent..." | (spacer) + */ + public void showWaitingMode() { + if (footer == null) return; + footer.clearChildren(); + footer.add(unconfirmBtn).width(130).height(48).left(); + footer.add(waitingLabel).expandX().center().padLeft(8).padRight(8); + footer.add().width(130); // mirror spacer so waiting label stays centred + footer.layout(); + } + + // ── Individual updates ──────────────────────────────────────────────────── + + public void setConfirmEnabled(boolean enabled) { + if (confirmBtn != null) confirmBtn.setDisabled(!enabled); + } + + public void setStatusMessage(String msg) { + if (statusLabel != null) statusLabel.setText(msg); + } + + // ── Legacy compatibility ────────────────────────────────────────────────── + + /** @deprecated Use showWaitingMode() / showSetupMode() directly. */ + @Deprecated + public void setWaitingMode(boolean waiting) { + if (waiting) showWaitingMode(); else showSetupMode(); + } + + /** @deprecated Use showWaitingMode() / showSetupMode() directly. */ + @Deprecated + public void setConfirmed(boolean confirmed) { + if (confirmed) showWaitingMode(); else showSetupMode(); + } + + /** @deprecated No longer needed — waiting label is embedded. */ + @Deprecated + public Label getWaitingLabel() { return waitingLabel; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java new file mode 100644 index 0000000..b109c1e --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java @@ -0,0 +1,136 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.utils.Align; +import com.group14.regicidechess.model.Player; + +/** + * SetupHeaderWidget — builds and manages the header UI. + * + * Layout (mirrors GameScreen top bar): + * + * [ SETUP (title) ] [ Budget: X / Y ] [ ● ms / Opp: -- ms ] + * left center right (connection group) + * + * The connection group contains: + * • connectionIcon — colour-coded dot (green/orange/red) + * • connectionLabel — own RTT in ms, or "Lost" + * • opponentLatencyLabel — opponent's published RTT + */ +public class SetupHeaderWidget { + private final Skin skin; + private final Player player; + + // Budget label — refreshed when pieces are placed/removed. + private Label budgetLabel; + + // Latency labels — updated by SetupScreen from SetupNetworkHandler callbacks. + private Label myLatencyLabel; + private Label opponentLatencyLabel; + + // Connection icon — colour updated alongside myLatencyLabel. + private Image connectionIcon; + private Label connectionLabel; + + public SetupHeaderWidget(Skin skin, Player player) { + this.skin = skin; + this.player = player; + } + + // ───────────────────────────────────────────────────────────────────────── + // Build + // ───────────────────────────────────────────────────────────────────────── + + public Table build() { + Table header = new Table(); + header.setBackground(skin.getDrawable("primary-pixel")); + header.pad(12); + + // Left — screen title + Label titleLabel = new Label("SETUP", skin, "title"); + + // Centre — budget remaining + budgetLabel = new Label(getBudgetText(), skin); + budgetLabel.setAlignment(Align.center); + + // Right — connection group (icon + own RTT, then opponent RTT below) + connectionIcon = new Image(skin.getDrawable("white-pixel")); + connectionIcon.setColor(Color.GRAY); + connectionLabel = new Label("Connecting", skin, "small"); + + opponentLatencyLabel = new Label("Opp: --", skin, "small"); + opponentLatencyLabel.setAlignment(Align.right); + + myLatencyLabel = new Label("Ping: --", skin, "small"); + myLatencyLabel.setAlignment(Align.right); + + // Connection sub-table (icon + "Connecting" / RTT on first row, + // opponent latency on second row) + Table connGroup = new Table(); + connGroup.add(connectionIcon).size(12, 12).padRight(6); + connGroup.add(connectionLabel).right(); + connGroup.row(); + connGroup.add(opponentLatencyLabel).colspan(2).right(); + + header.add(titleLabel).expandX().left(); + header.add(budgetLabel).expandX().center(); + header.add(connGroup).width(120).right().padRight(4); + + return header; + } + + // ───────────────────────────────────────────────────────────────────────── + // Refresh helpers (called from SetupScreen) + // ───────────────────────────────────────────────────────────────────────── + + public void refreshBudget() { + if (budgetLabel != null) { + budgetLabel.setText(getBudgetText()); + } + } + + /** + * Update own ping display. + * @param latencyMs measured RTT in ms, or -1 to show "Lost". + */ + public void setMyLatency(long latencyMs) { + if (connectionLabel == null || connectionIcon == null) return; + if (latencyMs < 0) { + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + if (myLatencyLabel != null) myLatencyLabel.setText("Ping: Lost"); + } else { + if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); + else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE); + else connectionIcon.setColor(Color.RED); + connectionLabel.setText(latencyMs + " ms"); + if (myLatencyLabel != null) myLatencyLabel.setText("Ping: " + latencyMs + " ms"); + } + } + + /** + * Update opponent ping display. + * @param latencyMs opponent's published RTT in ms, or -1 to show "Lost". + */ + public void setOpponentLatency(long latencyMs) { + if (opponentLatencyLabel == null) return; + if (latencyMs < 0) { + opponentLatencyLabel.setText("Opp: Lost"); + } else { + opponentLatencyLabel.setText("Opp: " + latencyMs + " ms"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Internal + // ───────────────────────────────────────────────────────────────────────── + + private String getBudgetText() { + return "Budget: " + player.getBudgetRemaining() + " / " + player.getBudget(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java new file mode 100644 index 0000000..300a758 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java @@ -0,0 +1,388 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.utils.Timer; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Player; + +/** + * SetupNetworkHandler — owns all Firebase subscriptions during the setup phase. + * + * Disconnect / reconnect grace-period design (mirrors GameNetworkHandler) + * ──────────────────────────────────────────────────────────────────────── + * When a player loses their Firebase connection the server writes a timestamp + * to games/{gameId}/disconnectedAt/{color} via a pre-registered onDisconnect + * hook. + * + * Disconnected player — local (client-side) selfCancelTask. + * After RECONNECT_GRACE_MS with no reconnect, fires + * onGameCancelled locally. Cannot use Firebase while + * offline, so this must be purely client-side. + * + * Connected player — server-side-triggered (client-side) autoCancelTask. + * Started when disconnectedAt/{opponentColor} appears + * in Firebase. After RECONNECT_GRACE_MS writes + * "cancel:{loserColor}" to games/{gameId}/gameOver + * (first-writer-wins transaction) and fires + * onGameCancelled locally so the cancelled screen is + * immediate. + * + * Reconnect path + * ────────────── + * 1. selfConnected → true → selfCancelTask cancelled. + * 2. api.signalReconnected() clears disconnectedAt/{color} and writes + * reconnected/{color} = true. + * 3. Connected player's listenForOpponentReconnected fires → autoCancelTask + * cancelled → setup resumes. + * + * Latency / heartbeat + * ──────────────────── + * Heartbeats are sent by SetupScreen on a fixed timer (same as GameScreen). + * This handler wires the listeners so both players can see each other's ping + * in the header, identical to the GameScreen top bar. + */ +public class SetupNetworkHandler { + + public interface Listener { + /** My own measured RTT (shown in the header). */ + void onMyLatency(long latencyMs); + + /** Opponent's published latency (shown in the header). */ + void onOpponentLatency(long latencyMs); + + /** I lost my own Firebase connection. */ + void onSelfDisconnected(); + + /** I regained my own Firebase connection. */ + void onSelfReconnected(); + + /** Opponent's disconnectedAt timestamp appeared — they dropped. */ + void onOpponentDisconnected(); + + /** Opponent came back within the grace window — cancel UI countdown. */ + void onOpponentReconnected(); + + /** + * Called when the disconnect grace period expires for either player + * and the game can no longer continue. Both players receive this. + */ + void onGameCancelled(); + } + + // Heartbeat timeout before the connected player starts the grace timer. + private static final long HEARTBEAT_TIMEOUT_MS = 5_000L; + // Grace period before cancellation (same as GameScreen). + private static final long RECONNECT_GRACE_MS = 10_000L; + + private final String gameId; + private final Player localPlayer; + private final Listener listener; + private final FirebaseAPI api; + + // Visual connection indicator (identical widgets to GameScreen top bar). + private final Image connectionIcon; + private final Label connectionLabel; + + // Connected player's auto-cancel timer. + private Timer.Task autoCancelTask = null; + + // Disconnected player's local cancel timer. + private Timer.Task selfCancelTask = null; + + private boolean opponentDisconnected = false; + private boolean selfConnected = true; + private boolean cancelWritten = false; // prevent double-write + + public SetupNetworkHandler(String gameId, Player localPlayer, Listener listener, + Image connectionIcon, Label connectionLabel, + FirebaseAPI api) { + this.gameId = gameId; + this.localPlayer = localPlayer; + this.listener = listener; + this.connectionIcon = connectionIcon; + this.connectionLabel = connectionLabel; + this.api = api; + } + + // ───────────────────────────────────────────────────────────────────────── + // Start / stop + // ───────────────────────────────────────────────────────────────────────── + + public void start() { + registerDisconnectSignal(); + startSelfConnectionListener(); + startGameCancelledListener(); // listens for gameOver written by the connected player + startHeartbeatAndLatency(); + startOpponentDisconnectedAtListener(); + startOpponentReconnectListener(); + } + + /** + * Full stop: cancels all timers AND removes all Firebase listeners. + * Call this when setup ends abnormally (game cancelled, back to menu). + */ + public void stop() { + cancelAutoCancelTimer(); + cancelSelfCancelTimer(); + api.removeAllListeners(); + } + + /** + * Transition stop: cancels local timers but does NOT remove Firebase listeners. + * + * Call this instead of stop() when handing off to GameScreen so the underlying + * Firebase socket and .info/connected subscription remain alive across the + * screen switch. Tearing down and re-registering listeners during the transition + * causes Firebase to emit .info/connected = false momentarily (listener detach/ + * re-attach gap), which both handlers interpret as a disconnect and immediately + * start their 10-second grace timers — cancelling the game before it begins. + * + * GameNetworkHandler.start() will add its own fresh listeners on top of the + * still-live socket without any interruption. + */ + public void stopForTransition() { + cancelAutoCancelTimer(); + cancelSelfCancelTimer(); + // Intentionally NOT calling api.removeAllListeners() — the Firebase socket + // stays connected. GameNetworkHandler will add its own listeners on the same + // FirebaseAPI instance and call removeAllListeners() when the game ends. + } + + /** + * Returns whether this device currently has a live Firebase connection. + * Passed to GameNetworkHandler so it can seed selfConnected correctly and + * not misread the initial .info/connected = true callback as a "reconnect". + */ + public boolean isSelfConnected() { + return selfConnected; + } + + // ───────────────────────────────────────────────────────────────────────── + // Outgoing + // ───────────────────────────────────────────────────────────────────────── + + public void sendHeartbeat() { + api.sendHeartbeat(gameId, localPlayer.isWhite()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Server-side disconnect hook + // ───────────────────────────────────────────────────────────────────────── + + private void registerDisconnectSignal() { + String myColor = localPlayer.isWhite() ? "white" : "black"; + // Writes disconnectedAt/{color} on drop — NOT gameOver. + // The connected opponent starts their grace timer from this signal. + api.registerDisconnectGameOver(gameId, "disconnect:" + myColor); + } + + // ───────────────────────────────────────────────────────────────────────── + // Own connection — .info/connected + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfConnectionListener() { + api.listenForMyConnection( + // onConnected + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) { + selfConnected = true; + Gdx.app.log("SetupNetworkHandler", "Self reconnected to Firebase"); + cancelSelfCancelTimer(); + // Clear disconnectedAt/{myColor} and write reconnected/{myColor}. + api.signalReconnected(gameId, localPlayer.isWhite()); + listener.onSelfReconnected(); + } + }), + // onDisconnected + () -> Gdx.app.postRunnable(() -> { + if (selfConnected) { + selfConnected = false; + Gdx.app.log("SetupNetworkHandler", "Self disconnected from Firebase"); + cancelAutoCancelTimer(); // can't be the connected player while offline + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + startSelfCancelTimer(); + listener.onSelfDisconnected(); + } + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Incoming: gameOver listener (re-uses the same Firebase path as GameScreen) + // ───────────────────────────────────────────────────────────────────────── + + private void startGameCancelledListener() { + // The connected player writes "cancel:{color}" via autoCancelTask. + // Both players see it here and show the cancellation screen. + api.listenForGameOver(gameId, reason -> + Gdx.app.postRunnable(() -> { + if (reason != null && reason.startsWith("cancel:")) { + listener.onGameCancelled(); + } + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Heartbeat & latency + // ───────────────────────────────────────────────────────────────────────── + + private void startHeartbeatAndLatency() { + // Each player listens for the *other* player's heartbeat. + boolean watchOpponent = !localPlayer.isWhite(); + + api.listenForHeartbeat( + gameId, + watchOpponent, + HEARTBEAT_TIMEOUT_MS, + // ── Heartbeat received ──────────────────────────────────────── + latency -> Gdx.app.postRunnable(() -> { + boolean wasDisconnected = opponentDisconnected; + opponentDisconnected = false; + cancelAutoCancelTimer(); + + updateMyConnectionUI(latency); + listener.onMyLatency(latency); + // Publish our own measured RTT so the opponent can display it. + api.sendLatency(gameId, localPlayer.isWhite(), latency); + + if (wasDisconnected) { + listener.onOpponentReconnected(); + } + }), + // ── Timeout: no heartbeat from opponent ─────────────────────── + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) return; // we're the offline one + if (opponentDisconnected) return; // grace timer already running + + opponentDisconnected = true; + Gdx.app.log("SetupNetworkHandler", + "Heartbeat timeout — opponent disconnected, starting 10s grace timer"); + listener.onOpponentDisconnected(); + startAutoCancelTimer(); + }) + ); + + api.listenForOpponentLatency(gameId, watchOpponent, + latency -> Gdx.app.postRunnable(() -> listener.onOpponentLatency(latency))); + } + + // ───────────────────────────────────────────────────────────────────────── + // disconnectedAt listener — secondary fallback (connected player only) + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentDisconnectedAtListener() { + String opponentColor = localPlayer.isWhite() ? "black" : "white"; + api.listenForOpponentDisconnectedAt(gameId, opponentColor, () -> + Gdx.app.postRunnable(() -> { + if (opponentDisconnected) return; // heartbeat already started timer + if (!selfConnected) return; // we're the offline one + + opponentDisconnected = true; + Gdx.app.log("SetupNetworkHandler", + "disconnectedAt signal — starting grace timer (fallback path)"); + listener.onOpponentDisconnected(); + startAutoCancelTimer(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Opponent reconnect listener (connected player only) + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentReconnectListener() { + boolean watchWhite = !localPlayer.isWhite(); + api.listenForOpponentReconnected(gameId, watchWhite, () -> + Gdx.app.postRunnable(() -> { + Gdx.app.log("SetupNetworkHandler", + "Opponent signalled reconnect — cancelling cancel timer"); + cancelAutoCancelTimer(); + opponentDisconnected = false; + listener.onOpponentReconnected(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Connected player's auto-cancel timer + // + // On expiry: writes "cancel:{droppedColor}" to gameOver (first-writer-wins) + // and fires onGameCancelled locally for immediate UI response. + // ───────────────────────────────────────────────────────────────────────── + + private void startAutoCancelTimer() { + cancelAutoCancelTimer(); + autoCancelTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (!opponentDisconnected || cancelWritten || !selfConnected) return; + cancelWritten = true; + String droppedColor = localPlayer.isWhite() ? "black" : "white"; + Gdx.app.log("SetupNetworkHandler", + "Grace period expired — writing cancel:" + droppedColor); + api.signalGameOver(gameId, "cancel:" + droppedColor); + // Show cancellation screen immediately without waiting for + // Firebase to echo the write back. + listener.onGameCancelled(); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelAutoCancelTimer() { + if (autoCancelTask != null) { + autoCancelTask.cancel(); + autoCancelTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Disconnected player's local cancel timer + // + // Started when .info/connected goes false. + // Cancelled if .info/connected returns true within the grace window. + // On expiry: fires onGameCancelled locally (cannot write Firebase offline). + // The server-side onDisconnect hook ensures the connected player's + // autoCancelTimer eventually fires and writes the gameOver reason. + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfCancelTimer() { + cancelSelfCancelTimer(); + selfCancelTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (selfConnected) return; // reconnected in time + Gdx.app.log("SetupNetworkHandler", + "Self reconnect grace expired — showing cancellation locally"); + listener.onGameCancelled(); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelSelfCancelTimer() { + if (selfCancelTask != null) { + selfCancelTask.cancel(); + selfCancelTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Connection indicator (identical to GameScreen) + // ───────────────────────────────────────────────────────────────────────── + + private void updateMyConnectionUI(long latencyMs) { + if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); + else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE); + else connectionIcon.setColor(Color.RED); + connectionLabel.setText(latencyMs + " ms"); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java new file mode 100644 index 0000000..a32028c --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java @@ -0,0 +1,150 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; + +/** + * SetupPaletteWidget — builds the scrollable piece-selection palette. + */ +public class SetupPaletteWidget { + + public static final String[] PIECE_NAMES = { + "King", "Queen", "Rook", "Bishop", "Knight", "Pawn" + }; + + public static final int[] PIECE_COSTS = { 0, 9, 5, 3, 3, 1 }; + + public interface Listener { + void onSelectionChanged(int selectedIndex); + } + + private final Skin skin; + private final Listener listener; + private final TextButton[] buttons; + private int selectedIndex = -1; + + // Option to keep selection after placing a piece + private boolean keepSelectionAfterPlace = true; + + public SetupPaletteWidget(Skin skin, Listener listener) { + this.skin = skin; + this.listener = listener; + this.buttons = new TextButton[PIECE_NAMES.length]; + } + + public SetupPaletteWidget(Skin skin, Listener listener, boolean keepSelectionAfterPlace) { + this(skin, listener); + this.keepSelectionAfterPlace = keepSelectionAfterPlace; + } + + public Table buildWidget() { + Table paletteWrapper = new Table(); + paletteWrapper.setBackground(skin.getDrawable("primary-dark-pixel")); + paletteWrapper.pad(8); + + Table palette = new Table(); + + for (int i = 0; i < PIECE_NAMES.length; i++) { + final int idx = i; + String cost = PIECE_COSTS[i] == 0 ? "free" : String.valueOf(PIECE_COSTS[i]); + String label = "\n\n" + PIECE_NAMES[i] + "\n[" + cost + "]"; + + TextButton btn = new TextButton(label, skin, "default"); + btn.getLabel().setAlignment(Align.center); + buttons[i] = btn; + + btn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + toggle(idx); + } + }); + palette.add(btn).width(68).height(90).pad(4); + } + + ScrollPane scroll = new ScrollPane(palette, skin); + scroll.setScrollingDisabled(false, true); + paletteWrapper.add(scroll).expandX().fillX().height(100); + return paletteWrapper; + } + + private void toggle(int idx) { + selectedIndex = (selectedIndex == idx) ? -1 : idx; + refreshButtonStyles(); + if (listener != null) { + listener.onSelectionChanged(selectedIndex); + } + } + + /** + * Clears the selection (use this when you want to force deselection). + */ + public void clearSelection() { + selectedIndex = -1; + refreshButtonStyles(); + } + + /** + * Called after a piece is placed. If keepSelectionAfterPlace is true, + * selection remains; otherwise it's cleared. + */ + public void afterPiecePlaced() { + if (!keepSelectionAfterPlace) { + clearSelection(); + } + } + + private void refreshButtonStyles() { + for (int i = 0; i < buttons.length; i++) { + buttons[i].setStyle(skin.get( + i == selectedIndex ? "accent" : "default", + TextButton.TextButtonStyle.class)); + } + } + + public ChessPiece createSelectedPiece(Player owner) { + return createPiece(selectedIndex, owner); + } + + public static ChessPiece createPiece(int idx, Player owner) { + switch (idx) { + case 0: return new King(owner); + case 1: return new Queen(owner); + case 2: return new Rook(owner); + case 3: return new Bishop(owner); + case 4: return new Knight(owner); + case 5: return new Pawn(owner); + default: return null; + } + } + + public TextButton[] getButtons() { + return buttons; + } + + public int getSelectedIndex() { + return selectedIndex; + } + + public boolean hasSelection() { + return selectedIndex >= 0; + } + + public void setKeepSelectionAfterPlace(boolean keep) { + this.keepSelectionAfterPlace = keep; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java new file mode 100644 index 0000000..e07452a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java @@ -0,0 +1,709 @@ +// SetupScreen.java - with heartbeat, latency display, and disconnect/cancel logic +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.screens.game.GameScreen; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; +import com.group14.regicidechess.states.SetupState; +import com.group14.regicidechess.utils.ResourceManager; + +import java.util.Timer; +import java.util.TimerTask; + + +public class SetupScreen implements Screen, + ScreenInputHandler.ScreenInputObserver, + SetupNetworkHandler.Listener { + + // ── Heartbeat ───────────────────────────────────────────────────────────── + private static final long HEARTBEAT_INTERVAL_MS = 2_000L; + + // ── LibGDX ──────────────────────────────────────────────────────────────── + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // ── Game state ──────────────────────────────────────────────────────────── + private final SetupState setupState; + private final Player localPlayer; + private final String gameId; + @SuppressWarnings("unused") + private final boolean isHost; + private boolean isConfirmed = false; + + // ── UI Widgets ──────────────────────────────────────────────────────────── + private final SetupHeaderWidget headerWidget; + private final SetupFooterWidget footerWidget; + private final SetupPaletteWidget palette; + + // ── Helpers ─────────────────────────────────────────────────────────────── + private final SetupBoardRenderer boardRenderer; + private SetupBoardInputHandler boardInputHandler; + private SetupFlowController flowController; + private SetupNetworkHandler networkHandler; + + // ── Heartbeat timer ─────────────────────────────────────────────────────── + private Timer heartbeatTimer; + private boolean isHeartbeatRunning = false; + private boolean isScreenActive = true; + + // ── Reconnect countdown (shown to the disconnected player) ──────────────── + private Timer reconnectCountdownTimer; + private int reconnectSecondsLeft = 0; + + // ── Game-cancelled overlay ──────────────────────────────────────────────── + private Table cancelledOverlay; + private boolean gameCancelledShown = false; + + // ── Transition guard ────────────────────────────────────────────────────── + // Set to true by onOpponentBoardFetched() after stopForTransition() is called. + // hide() checks this flag to avoid calling stop() (which calls removeAllListeners()) + // AFTER GameNetworkHandler.start() has already added its own listeners on the same + // FirebaseAPI instance — that would tear down the game's listeners immediately. + private boolean transitioningToGame = false; + + @SuppressWarnings("unused") + private Table root; + + // ───────────────────────────────────────────────────────────────────────── + // Constructor + // ───────────────────────────────────────────────────────────────────────── + + public SetupScreen(Game game, SpriteBatch batch, + String gameId, int boardSize, int budget, boolean isHost) { + this.game = game; + this.batch = batch; + this.gameId = gameId; + this.isHost = isHost; + + try { + // Host = white (player1), joiner = black (player2) + this.localPlayer = new Player(isHost ? "player1" : "player2", isHost, budget); + + this.setupState = new SetupState(); + setupState.setBoardSize(boardSize); + setupState.setBudget(budget); + setupState.setPlayer(localPlayer); + setupState.enter(); + + this.stage = new Stage(new FitViewport( + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT), batch); + this.skin = ResourceManager.getInstance().getSkin(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + this.palette = new SetupPaletteWidget(skin, this::onPaletteSelectionChanged, true); + + BoardCoordinateMapper coordinateMapper = new BoardCoordinateMapper(localPlayer, boardSize); + + this.boardRenderer = new SetupBoardRenderer(batch, coordinateMapper); + this.headerWidget = new SetupHeaderWidget(skin, localPlayer); + this.footerWidget = new SetupFooterWidget(skin, createFooterListener()); + + this.boardInputHandler = new SetupBoardInputHandler( + setupState, boardRenderer, palette, localPlayer, createBoardActionListener(), false); + + this.flowController = new SetupFlowController( + gameId, localPlayer, setupState, createFlowListener()); + + // Network handler — connection icon/label live inside SetupHeaderWidget + // but we need them before build(), so we create temporary widgets here + // that the header will display once build() is called in buildUI(). + // The simplest approach: let SetupHeaderWidget own them and expose + // setMyLatency / setOpponentLatency helpers (see SetupHeaderWidget.java). + // We pass dummy Image/Label here because SetupNetworkHandler uses them + // only for the colour-coded dot; the header's own labels handle text. + Image connIcon = new Image(skin.getDrawable("white-pixel")); + Label connLabel = new Label("Connecting", skin, "small"); + connIcon.setColor(Color.GRAY); + + FirebaseAPI api = setupState.getFirebaseApi(); + this.networkHandler = new SetupNetworkHandler( + gameId, localPlayer, this, connIcon, connLabel, api); + + buildUI(); + boardRenderer.computeGeometry( + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT, + SetupScreenConfig.HEADER_HEIGHT, + SetupScreenConfig.PALETTE_HEIGHT, + SetupScreenConfig.FOOTER_HEIGHT, + boardSize); + + Gdx.app.log("SetupScreen", + "Initialized with board size: " + boardSize + ", budget: " + budget); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error initializing SetupScreen: " + e.getMessage(), e); + showErrorAndExit("Failed to initialize setup: " + e.getMessage()); + throw new RuntimeException("SetupScreen initialization failed", e); + } + } + + private void showErrorAndExit(String message) { + Gdx.app.error("SetupScreen", message); + try { + game.setScreen(new MainMenuScreen(game, batch)); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Could not return to main menu", e); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // UI construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildUI() { + try { + root = new Table(); + root.setFillParent(true); + root.top(); + stage.addActor(root); + + root.add(headerWidget.build()) + .expandX().fillX() + .height(SetupScreenConfig.HEADER_HEIGHT) + .row(); + + root.add().expandX().expandY().row(); // board spacer + + root.add(palette.buildWidget()) + .expandX().fillX() + .height(SetupScreenConfig.PALETTE_HEIGHT) + .row(); + + root.add(footerWidget.build()) + .expandX().fillX() + .height(SetupScreenConfig.FOOTER_HEIGHT) + .row(); + + // NOTE: do NOT add footerWidget.getWaitingLabel() here. + // SetupFooterWidget.showWaitingMode() embeds the waiting label + // inside the footer table itself. Adding it here as a second + // actor would re-parent it out of the footer (Scene2D silently + // removes an actor from its current parent when it is added to + // another), breaking the waiting-mode layout. + buildCancelledOverlay(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error building UI: " + e.getMessage(), e); + } + } + + /** + * Builds the "Game Cancelled" overlay. It starts invisible and is shown + * on top of all other UI via stage.addActor() when onGameCancelled() fires. + * The overlay dims the background and shows a centred card with a message + * and a "Back to Menu" button — identical in structure to GameScreen's + * game-over overlay. + */ + private void buildCancelledOverlay() { + cancelledOverlay = new Table(); + cancelledOverlay.setFillParent(true); + cancelledOverlay.setVisible(false); + + // Semi-transparent dark card + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(32); + + Label title = new Label("Game Cancelled", skin, "title"); + title.setAlignment(Align.center); + + Label msg = new Label( + "A player disconnected.\nReturning to the main menu.", + skin, "small"); + msg.setAlignment(Align.center); + msg.setWrap(true); + + TextButton backBtn = new TextButton("Back to Menu", skin); + backBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + navigateToMainMenu(); + } + }); + + card.add(title).expandX().center().padBottom(16).row(); + card.add(msg).width(320).center().padBottom(24).row(); + card.add(backBtn).width(200).height(56).center().row(); + + cancelledOverlay.add(card).width(360).center(); + + stage.addActor(cancelledOverlay); + } + + // ───────────────────────────────────────────────────────────────────────── + // Heartbeat timer management (mirrors GameScreen exactly) + // ───────────────────────────────────────────────────────────────────────── + + private void startHeartbeatTimer() { + if (isHeartbeatRunning) return; + heartbeatTimer = new Timer(true); + heartbeatTimer.scheduleAtFixedRate(new TimerTask() { + @Override public void run() { + if (isScreenActive) { + Gdx.app.postRunnable(() -> networkHandler.sendHeartbeat()); + } + } + }, 0, HEARTBEAT_INTERVAL_MS); + isHeartbeatRunning = true; + Gdx.app.log("SetupScreen", "Heartbeat timer started"); + } + + private void stopHeartbeatTimer() { + if (heartbeatTimer != null) { + heartbeatTimer.cancel(); + heartbeatTimer = null; + } + isHeartbeatRunning = false; + } + + // ───────────────────────────────────────────────────────────────────────── + // Reconnect countdown (shown to the disconnected player) + // Mirrors GameScreen.startReconnectCountdown() exactly. + // ───────────────────────────────────────────────────────────────────────── + + private void startReconnectCountdown() { + stopReconnectCountdown(); + reconnectSecondsLeft = 10; + showStatus("You are disconnected! Reconnecting... " + reconnectSecondsLeft + "s"); + reconnectCountdownTimer = new Timer(true); + reconnectCountdownTimer.scheduleAtFixedRate(new TimerTask() { + @Override public void run() { + reconnectSecondsLeft--; + if (reconnectSecondsLeft <= 0) { + Gdx.app.postRunnable(() -> showStatus("Disconnected.")); + stopReconnectCountdown(); + } else { + showStatus("You are disconnected! Reconnecting... " + reconnectSecondsLeft + "s"); + } + } + }, 1_000L, 1_000L); + } + + private void stopReconnectCountdown() { + if (reconnectCountdownTimer != null) { + reconnectCountdownTimer.cancel(); + reconnectCountdownTimer = null; + } + reconnectSecondsLeft = 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // SetupNetworkHandler.Listener implementation + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onMyLatency(long latencyMs) { + Gdx.app.postRunnable(() -> headerWidget.setMyLatency(latencyMs)); + } + + @Override + public void onOpponentLatency(long latencyMs) { + Gdx.app.postRunnable(() -> headerWidget.setOpponentLatency(latencyMs)); + } + + @Override + public void onSelfDisconnected() { + stopHeartbeatTimer(); + Gdx.app.postRunnable(() -> { + headerWidget.setMyLatency(-1); // shows "Lost" + startReconnectCountdown(); + }); + } + + @Override + public void onSelfReconnected() { + // stopReconnectCountdown / startHeartbeatTimer are thread-safe (Timer cancel + // and schedule), but sendHeartbeat() and all UI updates must run on the GDX + // render thread. + stopReconnectCountdown(); + startHeartbeatTimer(); + Gdx.app.postRunnable(() -> { + networkHandler.sendHeartbeat(); + headerWidget.setMyLatency(0); // will be updated on next heartbeat cycle + showStatus("Reconnected!"); + }); + } + + @Override + public void onOpponentDisconnected() { + Gdx.app.postRunnable(() -> { + headerWidget.setOpponentLatency(-1); // shows "Opp: Lost" + showStatus("Opponent disconnected. Waiting for reconnection... (10s)"); + }); + } + + @Override + public void onOpponentReconnected() { + Gdx.app.postRunnable(() -> showStatus( + isConfirmed + ? "Opponent reconnected. Waiting for both to confirm..." + : "Opponent reconnected. Place your pieces.")); + } + + @Override + public void onGameCancelled() { + Gdx.app.postRunnable(() -> { + if (gameCancelledShown) return; + gameCancelledShown = true; + stopHeartbeatTimer(); + stopReconnectCountdown(); + showCancelledOverlay(); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Game-cancelled overlay + // ───────────────────────────────────────────────────────────────────────── + + private void showCancelledOverlay() { + if (cancelledOverlay != null) { + cancelledOverlay.setVisible(true); + // Bring to front so it draws over everything + cancelledOverlay.toFront(); + } + } + + private void navigateToMainMenu() { + stopHeartbeatTimer(); + stopReconnectCountdown(); + networkHandler.stop(); + game.setScreen(new MainMenuScreen(game, batch)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Listeners (footer / board / flow) + // ───────────────────────────────────────────────────────────────────────── + + private SetupFooterWidget.FooterListener createFooterListener() { + return new SetupFooterWidget.FooterListener() { + @Override + public void onClear() { + try { + if (isConfirmed) { + showStatus("Cannot clear board while confirmed. Press Unconfirm first."); + return; + } + setupState.clearBoard(); + palette.clearSelection(); + refreshUI(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error clearing board: " + e.getMessage(), e); + showStatus("Error clearing board: " + e.getMessage()); + } + } + + @Override + public void onConfirm() { + try { + flowController.confirm(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error confirming setup: " + e.getMessage(), e); + showStatus("Error confirming: " + e.getMessage()); + } + } + + @Override + public void onUnconfirm() { + try { + flowController.unconfirm(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error unconfirming setup: " + e.getMessage(), e); + showStatus("Error unconfirming: " + e.getMessage()); + } + } + }; + } + + private SetupBoardInputHandler.BoardActionListener createBoardActionListener() { + return new SetupBoardInputHandler.BoardActionListener() { + @Override public void onPiecePlaced(ChessPiece piece, int col, int row) { refreshUI(); } + @Override public void onPieceRemoved(int col, int row) { refreshUI(); } + @Override public void onPieceReplaced(ChessPiece oldPiece, ChessPiece newPiece, int col, int row) { refreshUI(); } + @Override public void onInvalidPlacement(String reason) { showStatus(reason); } + @Override public void onStateChanged() { refreshUI(); } + }; + } + + private SetupFlowController.FlowListener createFlowListener() { + return new SetupFlowController.FlowListener() { + @Override + public void onUploadComplete() { + Gdx.app.postRunnable(() -> { + try { + isConfirmed = true; + footerWidget.setConfirmEnabled(true); + footerWidget.showWaitingMode(); // single call — no redundant setConfirmed/setWaitingMode + updateBoardInputLock(true); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in onUploadComplete: " + e.getMessage(), e); + } + }); + } + + @Override + public void onUnconfirmSuccess() { + Gdx.app.postRunnable(() -> { + try { + isConfirmed = false; + footerWidget.showSetupMode(); // single call + footerWidget.setConfirmEnabled(setupState.isReadyForConfirm()); + updateBoardInputLock(false); + showStatus("Setup unlocked. You can now make changes."); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in onUnconfirmSuccess: " + e.getMessage(), e); + } + }); + } + + @Override + public void onUnconfirmError(String message) { + Gdx.app.postRunnable(() -> showStatus("Failed to unconfirm: " + message)); + } + + @Override + public void onBothReady() { + Gdx.app.log("SetupScreen", "Both players ready"); + } + + @Override + public void onOpponentBoardFetched(com.group14.regicidechess.model.Board finalBoard, Player opponent) { + Gdx.app.postRunnable(() -> { + if (transitioningToGame) return; + + try { + isScreenActive = false; + stopReconnectCountdown(); + stopHeartbeatTimer(); + boolean connectedNow = networkHandler.isSelfConnected(); + + transitioningToGame = true; + networkHandler.stopForTransition(); + + game.setScreen(new GameScreen( + game, + batch, + finalBoard, + localPlayer, + setupState.getBoardSize(), + gameId, + setupState.getFirebaseApi(), + connectedNow + )); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error transitioning to GameScreen: " + e.getMessage(), e); + } + }); + } + + @Override + public void onError(String message) { + Gdx.app.postRunnable(() -> { + showStatus(message); + isConfirmed = false; + footerWidget.showSetupMode(); // single call + footerWidget.setConfirmEnabled(true); + updateBoardInputLock(false); + }); + } + }; + } + + private void updateBoardInputLock(boolean locked) { + try { + this.boardInputHandler = new SetupBoardInputHandler( + setupState, boardRenderer, palette, localPlayer, createBoardActionListener(), locked); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error updating board input lock: " + e.getMessage(), e); + } + } + + private void onPaletteSelectionChanged(int selectedIndex) { + if (selectedIndex >= 0) { + Gdx.app.log("SetupScreen", "Selected piece: " + SetupPaletteWidget.PIECE_NAMES[selectedIndex]); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Rendering + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void render(float delta) { + try { + Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + setupState.update(delta); + + boardRenderer.drawBoard(stage.getCamera().combined, setupState); + stage.act(delta); + + // Draw dimming layer behind the overlay when it's visible. + if (gameCancelledShown) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + ShapeRenderer sr = new ShapeRenderer(); + sr.setProjectionMatrix(stage.getCamera().combined); + sr.begin(ShapeRenderer.ShapeType.Filled); + sr.setColor(0f, 0f, 0f, 0.65f); + sr.rect(0, 0, + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT); + sr.end(); + sr.dispose(); + Gdx.gl.glDisable(GL20.GL_BLEND); + } + + stage.draw(); + boardRenderer.drawPaletteSprites(stage.getCamera().combined, + palette.getButtons(), SetupPaletteWidget.PIECE_NAMES); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in render: " + e.getMessage(), e); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Input + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int screenX, int screenY, int pointer, int button) { + if (gameCancelledShown) return; // block board taps when cancelled overlay is up + try { + Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); + boardInputHandler.handleTap(world.x, world.y); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error handling tap: " + e.getMessage(), e); + } + } + + @Override public void onDrag(int x, int y, int pointer) {} + @Override public void onRelease(int x, int y, int pointer, int button) {} + @Override public void onKeyDown(int keycode) {} + + // ───────────────────────────────────────────────────────────────────────── + // UI refresh + // ───────────────────────────────────────────────────────────────────────── + + private void refreshUI() { + try { + headerWidget.refreshBudget(); + boolean kingPlaced = setupState.isReadyForConfirm(); + if (!isConfirmed) footerWidget.setConfirmEnabled(kingPlaced); + showStatus(kingPlaced + ? (isConfirmed ? "Setup confirmed! Press Unconfirm to make changes." : "Ready! Press Confirm when done.") + : "Place your King to continue."); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error refreshing UI: " + e.getMessage(), e); + } + } + + private void showStatus(String msg) { + try { + footerWidget.setStatusMessage(msg); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error showing status: " + e.getMessage(), e); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + try { + isScreenActive = true; + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + networkHandler.start(); + startHeartbeatTimer(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in show: " + e.getMessage(), e); + } + } + + @Override + public void resize(int width, int height) { + try { + stage.getViewport().update(width, height, true); + boardRenderer.computeGeometry( + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT, + SetupScreenConfig.HEADER_HEIGHT, + SetupScreenConfig.PALETTE_HEIGHT, + SetupScreenConfig.FOOTER_HEIGHT, + setupState.getBoardSize()); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in resize: " + e.getMessage(), e); + } + } + + @Override + public void pause() { + isScreenActive = false; + } + + @Override + public void resume() { + isScreenActive = true; + if (!gameCancelledShown) networkHandler.sendHeartbeat(); + } + + @Override + public void hide() { + try { + isScreenActive = false; + inputHandler.clearObservers(); + stopReconnectCountdown(); + stopHeartbeatTimer(); + // If we're transitioning to GameScreen, stopForTransition() was already + // called and GameNetworkHandler.start() has already registered its own + // listeners on the same FirebaseAPI instance. Calling stop() here would + // invoke removeAllListeners() and immediately tear down those game + // listeners, causing both devices to see a disconnect and start their + // 10-second grace timers before the game even begins. + if (!transitioningToGame) { + networkHandler.stop(); + } + setupState.exit(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in hide: " + e.getMessage(), e); + } + } + + @Override + public void dispose() { + try { + stopReconnectCountdown(); + stopHeartbeatTimer(); + stage.dispose(); + boardRenderer.dispose(); + } catch (Exception e) { + Gdx.app.error("SetupScreen", "Error in dispose: " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java new file mode 100644 index 0000000..16fc784 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java @@ -0,0 +1,22 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java +package com.group14.regicidechess.screens.setup; + +/** + * Configuration constants for the setup screen. + */ +public final class SetupScreenConfig { + private SetupScreenConfig() {} // Prevent instantiation + + // Viewport dimensions + public static final float VIEWPORT_WIDTH = 480f; + public static final float VIEWPORT_HEIGHT = 854f; + + // Layout heights + public static final float HEADER_HEIGHT = 80f; + public static final float PALETTE_HEIGHT = 130f; + public static final float FOOTER_HEIGHT = 70f; + + // Board fetch retry configuration + public static final int BOARD_FETCH_MAX_RETRIES = 5; + public static final long BOARD_FETCH_RETRY_MS = 600L; +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/states/GameState.java b/core/src/main/java/com/group14/regicidechess/states/GameState.java new file mode 100644 index 0000000..953fe5e --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/states/GameState.java @@ -0,0 +1,26 @@ +package com.group14.regicidechess.states; + +/** + * GameState — Abstract base for all application states (GoF State pattern). + * + * Placement: core/src/main/java/com/group14/regicidechess/states/GameState.java + * + * Each concrete state (MainMenuState, LobbyState, SetupState, InMatchState) + * encapsulates its own behaviour and manages its own transitions. + * The GUI layer drives the state via update() every frame, and calls + * enter() / exit() on screen transitions. + */ +public abstract class GameState { + + /** Called once when this state becomes active. */ + public abstract void enter(); + + /** + * Called every frame by the active Screen's render(). + * @param delta time since last frame in seconds + */ + public abstract void update(float delta); + + /** Called once when this state is replaced by another. */ + public abstract void exit(); +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/states/InMatchState.java b/core/src/main/java/com/group14/regicidechess/states/InMatchState.java new file mode 100644 index 0000000..f8256aa --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/states/InMatchState.java @@ -0,0 +1,49 @@ +package com.group14.regicidechess.states; + +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Move; +import com.group14.regicidechess.model.MoveHistory; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; + +/** Placement: core/src/main/java/com/group14/regicidechess/states/InMatchState.java */ +public class InMatchState extends GameState { + + private Board board; + private MoveHistory moveHistory; + private Player playerOne; + private Player playerTwo; + + @Override public void enter() {} + @Override public void update(float delta) {} + @Override public void exit() {} + + public void init(Board board, Player playerOne, Player playerTwo) { + this.board = board; + this.playerOne = playerOne; + this.playerTwo = playerTwo; + this.moveHistory = new MoveHistory(playerOne); + } + + public Player getCurrentTurn() { + return moveHistory.getCurrentTurn(); + } + + public boolean isMyTurn(Player player) { + return moveHistory.getCurrentTurn() == player; + } + + public ChessPiece executeMove(Vector2 from, Vector2 to) { + ChessPiece moving = board.getPieceAt(from); + ChessPiece captured = board.movePiece(from, to); + moveHistory.append(new Move(from, to, moving, getCurrentTurn())); + moveHistory.switchTurn(playerOne, playerTwo); + return captured; + } + + public Board getBoard() { return board; } + public MoveHistory getMoveHistory() { return moveHistory; } + public Player getPlayerOne() { return playerOne; } + public Player getPlayerTwo() { return playerTwo; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/states/LobbyState.java b/core/src/main/java/com/group14/regicidechess/states/LobbyState.java new file mode 100644 index 0000000..66c884c --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/states/LobbyState.java @@ -0,0 +1,88 @@ +package com.group14.regicidechess.states; + +import com.group14.regicidechess.database.DatabaseManager; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.model.Player; + +public class LobbyState extends GameState { + + private Lobby lobby; + private Player playerOne; + private Player playerTwo; + + @Override public void enter() {} + @Override public void update(float delta) {} + @Override public void exit() {} + + /** + * Creates a lobby locally and persists it to Firebase. + * onSuccess is called on the LibGDX thread with the generated Game ID. + */ + public void createLobby(int boardSize, int budget, + Runnable onSuccess, Runnable onError) { + lobby = new Lobby(boardSize, budget); + playerOne = new Player("player1", true, budget); + + DatabaseManager.getInstance().getApi().createLobby(lobby, + gameId -> com.badlogic.gdx.Gdx.app.postRunnable(onSuccess), + err -> { + com.badlogic.gdx.Gdx.app.log("LobbyState", "createLobby error: " + err); + com.badlogic.gdx.Gdx.app.postRunnable(onError); + }); + } + + /** + * Fetches an existing lobby from Firebase by Game ID. + * onSuccess is called with the populated Lobby on the LibGDX thread. + */ + public void joinLobby(String gameId, + Runnable onSuccess, Runnable onError) { + DatabaseManager.getInstance().getApi().joinLobby(gameId, + fetchedLobby -> com.badlogic.gdx.Gdx.app.postRunnable(() -> { + lobby = fetchedLobby; + playerTwo = new Player("player2", false, fetchedLobby.getBudget()); + onSuccess.run(); + }), + err -> { + com.badlogic.gdx.Gdx.app.log("LobbyState", "joinLobby error: " + err); + com.badlogic.gdx.Gdx.app.postRunnable(onError); + }); + } + + // Keep old stub method name for backwards compatibility (local-only testing) + public String generateGameId(int boardSize, int budget) { + lobby = new Lobby(boardSize, budget); + playerOne = new Player("player1", true, budget); + return lobby.getGameId(); + } + + /** Called when MainMenuScreen has already fetched the lobby. */ + public void setPrefetchedLobby(Lobby prefetched) { + this.lobby = prefetched; + if (prefetched != null) { + playerTwo = new Player("player2", false, prefetched.getBudget()); + } + } + + public void validateLobby(String gameId, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + DatabaseManager.getInstance().getApi().fetchLobby(gameId, onSuccess, onError); + } + + public void listenForOpponentReady(String gameId, Runnable onReady) { + DatabaseManager.getInstance().getApi().listenForOpponentReady(gameId, onReady); + } + + public void startGame(String gameId) { + DatabaseManager.getInstance().getApi().startGame(gameId); + } + + public void listenForGameStart(String gameId, Runnable onStart) { + DatabaseManager.getInstance().getApi().listenForGameStart(gameId, onStart); + } + + public Lobby getLobby() { return lobby; } + public Player getPlayerOne() { return playerOne; } + public Player getPlayerTwo() { return playerTwo; } + public void setPlayerTwo(Player p) { this.playerTwo = p; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/states/MainMenuState.java b/core/src/main/java/com/group14/regicidechess/states/MainMenuState.java new file mode 100644 index 0000000..6ed6821 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/states/MainMenuState.java @@ -0,0 +1,16 @@ +package com.group14.regicidechess.states; + +import com.group14.regicidechess.model.Player; + +/** Placement: core/src/main/java/com/group14/regicidechess/states/MainMenuState.java */ +public class MainMenuState extends GameState { + + private Player currentPlayer; + + @Override public void enter() {} + @Override public void update(float delta) {} + @Override public void exit() {} + + public Player getCurrentPlayer() { return currentPlayer; } + public void setCurrentPlayer(Player p) { this.currentPlayer = p; } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/states/SetupState.java b/core/src/main/java/com/group14/regicidechess/states/SetupState.java new file mode 100644 index 0000000..d02ab09 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/states/SetupState.java @@ -0,0 +1,187 @@ +// File: core/src/main/java/com/group14/regicidechess/states/SetupState.java +package com.group14.regicidechess.states; + +import com.group14.regicidechess.database.DatabaseManager; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; + +/** + * SetupState — manages the piece placement phase. + * + * Important: isReady() returns true if King is placed, NOT player.isReady(). + * The player.isReady() flag is used for the Firebase flow, not for UI readiness. + */ +public class SetupState extends GameState { + + private Board board; + private Player player; + private int boardSize; + private int budget; + private int homeRowMin; + private int homeRowMax; + + @Override + public void enter() { + com.badlogic.gdx.Gdx.app.log("SetupState", "Enter"); + } + + @Override + public void update(float delta) {} + + @Override + public void exit() { + com.badlogic.gdx.Gdx.app.log("SetupState", "Exit"); + } + + public void setBoardSize(int size) { + this.boardSize = size; + this.board = new Board(size); + com.badlogic.gdx.Gdx.app.log("SetupState", "Board size set to: " + size); + } + + public void setBudget(int budget) { + this.budget = budget; + if (player != null) player.resetBudget(); + com.badlogic.gdx.Gdx.app.log("SetupState", "Budget set to: " + budget); + } + + private static final int HOME_ROWS = 2; + + public void setPlayer(Player player) { + this.player = player; + if (player.isWhite()) { + homeRowMin = 0; + homeRowMax = HOME_ROWS - 1; // rows 0–1 + } else { + homeRowMin = boardSize - HOME_ROWS; // rows (size-2)–(size-1) + homeRowMax = boardSize - 1; + } + com.badlogic.gdx.Gdx.app.log("SetupState", "Player set: " + player.getPlayerId() + + ", home rows: " + homeRowMin + "-" + homeRowMax); + } + + public boolean placePiece(ChessPiece piece, int col, int row) { + if (!isInHomeZone(row)) { + com.badlogic.gdx.Gdx.app.log("SetupState", "Placement failed: not in home zone (row " + row + ")"); + return false; + } + + if (piece instanceof King && kingIsPlaced()) { + com.badlogic.gdx.Gdx.app.log("SetupState", "Placement failed: King already placed"); + return false; + } + + if (!player.spendBudget(piece.getPointCost())) { + com.badlogic.gdx.Gdx.app.log("SetupState", "Placement failed: insufficient budget"); + return false; + } + + ChessPiece existing = board.getPieceAt(col, row); + if (existing != null) { + player.refundBudget(existing.getPointCost()); + } + + board.placePiece(piece, col, row); + com.badlogic.gdx.Gdx.app.log("SetupState", "Piece placed: " + piece.getTypeName() + + " at (" + col + ", " + row + "), budget remaining: " + + player.getBudgetRemaining()); + return true; + } + + public void removePiece(int col, int row) { + ChessPiece removed = board.removePiece(col, row); + if (removed != null) { + player.refundBudget(removed.getPointCost()); + com.badlogic.gdx.Gdx.app.log("SetupState", "Piece removed: " + removed.getTypeName() + + " at (" + col + ", " + row + "), budget remaining: " + + player.getBudgetRemaining()); + } + } + + public void clearBoard() { + board.clear(); + player.resetBudget(); + com.badlogic.gdx.Gdx.app.log("SetupState", "Board cleared, budget reset"); + } + + /** + * Sets the player as ready for Firebase flow. + * This should ONLY be called when confirming the setup. + */ + public boolean setReady() { + if (!kingIsPlaced()) { + com.badlogic.gdx.Gdx.app.log("SetupState", "setReady() failed: King not placed"); + return false; + } + player.setReady(); + com.badlogic.gdx.Gdx.app.log("SetupState", "setReady() success, player ready flag set"); + return true; + } + + /** + * Checks if the player is ready for the Firebase flow. + * This is different from UI readiness (which only requires King). + */ + public boolean isPlayerReady() { + return player != null && player.isReady(); + } + + /** + * UI readiness: King must be placed. + * This is what the confirm button should check. + */ + public boolean isReadyForConfirm() { + return kingIsPlaced(); + } + + private boolean kingIsPlaced() { + for (ChessPiece p : board.getPieces(player)) { + if (p instanceof King) { + return true; + } + } + return false; + } + + private boolean isInHomeZone(int row) { + return row >= homeRowMin && row <= homeRowMax; + } + + public Board getBoard() { return board; } + public Player getPlayer() { return player; } + public int getBoardSize() { return boardSize; } + public int getBudget() { return budget; } + public int getHomeRowMin() { return homeRowMin; } + public int getHomeRowMax() { return homeRowMax; } + + public FirebaseAPI getFirebaseApi() { + return DatabaseManager.getInstance().getApi(); + } + + public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess) { + DatabaseManager.getInstance().getApi().confirmSetup(gameId, isWhite, board, onSuccess, null); + } + + public void unconfirmSetup(String gameId, boolean isWhite, Runnable onSuccess, FirebaseAPI.Callback onError) { + DatabaseManager.getInstance().getApi().unconfirmSetup(gameId, isWhite, onSuccess, onError); + } + + public void listenForBothSetupReady(String gameId, Runnable onBothReady) { + DatabaseManager.getInstance().getApi().listenForBothSetupReady(gameId, onBothReady); + } + + public void getOpponentBoard(String gameId, boolean localIsWhite, FirebaseAPI.Callback onBoard) { + DatabaseManager.getInstance().getApi().getOpponentBoard(gameId, localIsWhite, onBoard); + } + + /** + * @deprecated Use isReadyForConfirm() for UI readiness, or isPlayerReady() for Firebase readiness. + */ + @Deprecated + public boolean isReady() { + return isReadyForConfirm(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/utils/ResourceManager.java b/core/src/main/java/com/group14/regicidechess/utils/ResourceManager.java new file mode 100644 index 0000000..764f29b --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/utils/ResourceManager.java @@ -0,0 +1,288 @@ +package com.group14.regicidechess.utils; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.TextureRegion; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.utils.Disposable; + +import java.util.HashMap; +import java.util.Map; + +/** + * ResourceManager — Singleton that loads and caches shared assets. + * + * Placement: core/src/main/java/com/regicidechess/utils/ResourceManager.java + * + * Usage: + * ResourceManager rm = ResourceManager.getInstance(); + * Texture t = rm.getTexture("pieces/white_king"); + * + * Call dispose() once when the application shuts down. + */ +public class ResourceManager implements Disposable { + + // ------------------------------------------------------------------------- + // Singleton + // ------------------------------------------------------------------------- + + private static ResourceManager instance; + + private ResourceManager() {} + + public static ResourceManager getInstance() { + if (instance == null) { + instance = new ResourceManager(); + } + return instance; + } + + // ------------------------------------------------------------------------- + // Internal caches + // ------------------------------------------------------------------------- + + private final Map textures = new HashMap<>(); + private final Map fonts = new HashMap<>(); + + /** Generated 1×1 solid-colour textures used for plain rectangles. */ + private final Map solidColours = new HashMap<>(); + + private Skin defaultSkin; + + // ------------------------------------------------------------------------- + // Texture API + // ------------------------------------------------------------------------- + + /** + * Returns a cached Texture for the given asset path (relative to assets/). + * On the first call the texture is loaded from disk; subsequent calls return + * the cached instance. + * + * @param path e.g. "pieces/white_king.png" + */ + public Texture getTexture(String path) { + if (!textures.containsKey(path)) { + textures.put(path, new Texture(Gdx.files.internal(path))); + } + return textures.get(path); + } + + /** + * Returns a 1×1 Texture filled with the given colour. Useful for drawing + * coloured rectangles without a dedicated asset. + * + * @param color LibGDX Color (will be converted to RGBA8888) + */ + public Texture getSolidTexture(Color color) { + int rgba = Color.rgba8888(color); + if (!solidColours.containsKey(rgba)) { + Pixmap pm = new Pixmap(1, 1, Pixmap.Format.RGBA8888); + pm.setColor(color); + pm.fill(); + solidColours.put(rgba, new Texture(pm)); + pm.dispose(); + } + return solidColours.get(rgba); + } + + // ------------------------------------------------------------------------- + // Font API + // ------------------------------------------------------------------------- + + /** + * Returns a cached BitmapFont. Pass an empty string to get the built-in + * LibGDX default font. + * + * @param path e.g. "fonts/opensans.fnt" — or "" for the default font + */ + public BitmapFont getFont(String path) { + if (!fonts.containsKey(path)) { + if (path == null || path.isEmpty()) { + fonts.put(path, new BitmapFont()); + } else { + fonts.put(path, new BitmapFont(Gdx.files.internal(path))); + } + } + return fonts.get(path); + } + + // ------------------------------------------------------------------------- + // Skin API + // ------------------------------------------------------------------------- + + /** + * Returns a shared Skin built from a generated, plain-colour style. + * Replace the body of this method once you have a proper .json + atlas skin. + */ + public Skin getSkin() { + if (defaultSkin == null) { + defaultSkin = buildDefaultSkin(); + } + return defaultSkin; + } + + /** + * Builds a minimal programmatic Skin so screens can use Scene2D widgets + * (Label, TextButton, TextField …) before a real atlas is in place. + */ + private Skin buildDefaultSkin() { + Skin skin = new Skin(); + + // ── Colours ──────────────────────────────────────────────────────────── + skin.add("white", Color.WHITE); + skin.add("black", Color.BLACK); + skin.add("light-tile", new Color(0.93f, 0.85f, 0.72f, 1f)); + skin.add("dark-tile", new Color(0.55f, 0.38f, 0.24f, 1f)); + skin.add("primary", new Color(0.18f, 0.35f, 0.58f, 1f)); + skin.add("primary-dark",new Color(0.12f, 0.24f, 0.40f, 1f)); + skin.add("accent", new Color(0.85f, 0.65f, 0.13f, 1f)); + skin.add("background", new Color(0.12f, 0.12f, 0.15f, 1f)); + skin.add("surface", new Color(0.18f, 0.18f, 0.22f, 1f)); + skin.add("highlight", new Color(0.40f, 0.75f, 0.40f, 0.65f)); + skin.add("danger", new Color(0.75f, 0.22f, 0.22f, 1f)); + + // ── Default font ─────────────────────────────────────────────────────── + BitmapFont font = new BitmapFont(); + font.getData().setScale(1.5f); + skin.add("default-font", font); + + BitmapFont smallFont = new BitmapFont(); + skin.add("small-font", smallFont); + + BitmapFont largeFont = new BitmapFont(); + largeFont.getData().setScale(2.5f); + skin.add("large-font", largeFont); + + // ── Textures (1×1 solid colours for widget backgrounds) ─────────────── + addSolidDrawable(skin, "white-pixel", Color.WHITE); + addSolidDrawable(skin, "black-pixel", Color.BLACK); + addSolidDrawable(skin, "primary-pixel", new Color(0.18f, 0.35f, 0.58f, 1f)); + addSolidDrawable(skin, "primary-dark-pixel",new Color(0.12f, 0.24f, 0.40f, 1f)); + addSolidDrawable(skin, "accent-pixel", new Color(0.85f, 0.65f, 0.13f, 1f)); + addSolidDrawable(skin, "surface-pixel", new Color(0.18f, 0.18f, 0.22f, 1f)); + addSolidDrawable(skin, "danger-pixel", new Color(0.75f, 0.22f, 0.22f, 1f)); + addSolidDrawable(skin, "highlight-pixel", new Color(0.40f, 0.75f, 0.40f, 0.65f)); + addSolidDrawable(skin, "transparent-pixel", new Color(0f, 0f, 0f, 0f)); + + // ── Label style ──────────────────────────────────────────────────────── + com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle labelStyle = + new com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle(); + labelStyle.font = font; + labelStyle.fontColor = Color.WHITE; + skin.add("default", labelStyle); + + com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle titleStyle = + new com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle(); + titleStyle.font = largeFont; + titleStyle.fontColor = new Color(0.85f, 0.65f, 0.13f, 1f); + skin.add("title", titleStyle); + + com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle smallStyle = + new com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle(); + smallStyle.font = smallFont; + smallStyle.fontColor = Color.LIGHT_GRAY; + skin.add("small", smallStyle); + + // ── TextButton style ─────────────────────────────────────────────────── + com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle btnStyle = + new com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle(); + btnStyle.font = font; + btnStyle.up = skin.getDrawable("primary-pixel"); + btnStyle.down = skin.getDrawable("primary-dark-pixel"); + btnStyle.over = skin.getDrawable("primary-pixel"); + btnStyle.fontColor = Color.WHITE; + skin.add("default", btnStyle); + + com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle accentBtnStyle = + new com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle(); + accentBtnStyle.font = font; + accentBtnStyle.up = skin.getDrawable("accent-pixel"); + accentBtnStyle.down = skin.getDrawable("primary-dark-pixel"); + accentBtnStyle.over = skin.getDrawable("accent-pixel"); + accentBtnStyle.fontColor = Color.BLACK; + skin.add("accent", accentBtnStyle); + + com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle dangerBtnStyle = + new com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle(); + dangerBtnStyle.font = font; + dangerBtnStyle.up = skin.getDrawable("danger-pixel"); + dangerBtnStyle.down = skin.getDrawable("primary-dark-pixel"); + dangerBtnStyle.over = skin.getDrawable("danger-pixel"); + dangerBtnStyle.fontColor = Color.WHITE; + skin.add("danger", dangerBtnStyle); + + // ── TextField style ──────────────────────────────────────────────────── + com.badlogic.gdx.scenes.scene2d.ui.TextField.TextFieldStyle tfStyle = + new com.badlogic.gdx.scenes.scene2d.ui.TextField.TextFieldStyle(); + tfStyle.font = font; + tfStyle.fontColor = Color.WHITE; + tfStyle.background = skin.getDrawable("surface-pixel"); + tfStyle.cursor = skin.getDrawable("accent-pixel"); + tfStyle.selection = skin.getDrawable("highlight-pixel"); + tfStyle.messageFontColor = Color.GRAY; + tfStyle.messageFont = font; + skin.add("default", tfStyle); + + // ── ScrollPane style ────────────────────────────────────────────────── + com.badlogic.gdx.scenes.scene2d.ui.ScrollPane.ScrollPaneStyle scrollStyle = + new com.badlogic.gdx.scenes.scene2d.ui.ScrollPane.ScrollPaneStyle(); + skin.add("default", scrollStyle); + + return skin; + } + + /** Helper — creates a 1×1 Pixmap-backed NinePatchDrawable and adds it to the Skin. */ + private void addSolidDrawable(Skin skin, String name, Color color) { + Pixmap pm = new Pixmap(1, 1, Pixmap.Format.RGBA8888); + pm.setColor(color); + pm.fill(); + Texture tex = new Texture(pm); + pm.dispose(); + skin.add(name, tex); + skin.add(name, new com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable( + new TextureRegion(tex))); + } + + // ------------------------------------------------------------------------- + // Piece texture helpers + // ------------------------------------------------------------------------- + + /** + * Convenience wrapper: returns the texture for a chess piece. + * + * Convention: assets/pieces/{color}_{type}.png + * e.g. pieces/white_king.png + * + * Falls back to a generated placeholder if the file is missing. + * + * @param color "white" or "black" + * @param type "king", "queen", "rook", "bishop", "knight", "pawn" + */ + public Texture getPieceTexture(String color, String type) { + String path = "pieces/" + color + "_" + type + ".png"; + if (Gdx.files.internal(path).exists()) { + return getTexture(path); + } + return getSolidTexture(color.equals("white") ? Color.WHITE : Color.DARK_GRAY); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + @Override + public void dispose() { + for (Texture t : textures.values()) t.dispose(); + for (Texture t : solidColours.values()) t.dispose(); + for (BitmapFont f : fonts.values()) f.dispose(); + if (defaultSkin != null) defaultSkin.dispose(); + textures.clear(); + solidColours.clear(); + fonts.clear(); + defaultSkin = null; + instance = null; + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e68c282 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# This doesn't need to be false, and some projects may be able to take advantage of setting daemon to true. +# We set it to false by default in order to avoid too many daemons from being created and persisting; each needs RAM. +org.gradle.daemon=false +# Sets starting memory usage to 512MB, maximum memory usage to 1GB, and tries to set as much to use Unicode as we can. +org.gradle.jvmargs=-Xms512M -Xmx1G -Dfile.encoding=UTF-8 -Dconsole.encoding=UTF-8 +# "Configure on-demand" must be false because it breaks projects that have Android modules. The default is also false. +org.gradle.configureondemand=false +# The logging level determines which messages get shown about how Gradle itself is working, such as if build.gradle +# files are fully future-proof (which they never are, because Gradle constantly deprecates working APIs). +# You can change 'quiet' below to 'lifecycle' to use Gradle's default behavior, which shows some confusing messages. +# You could instead change 'quiet' below to 'info' to see info that's important mainly while debugging build files. +# Note that if you want to use Gradle Build Scans, you should set the below logging level to 'lifecycle', otherwise +# the link to the scan won't get shown at all. +# Documented at: https://docs.gradle.org/current/userguide/command_line_interface.html#sec:command_line_logging +org.gradle.logging.level=quiet +android.useAndroidX=true +android.enableR8.fullMode=false +graalHelperVersion=2.0.1 +enableGraalNative=false +gdxVersion=1.14.0 +projectVersion=1.0.0 diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..67ed422 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73c462e34475aeb6509ab7ba3eda218f/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73c462e34475aeb6509ab7ba3eda218f/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29c55e6bad8a0049163f0184625cecd9/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/3ac7a5361c25c0b23d933f44bdb0abd9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73c462e34475aeb6509ab7ba3eda218f/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/28937bb8a7f83f57de92429a9a11c04e/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/52fa104f4f641439587f75dd68b31bc2/redirect +toolchainVersion=17 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lwjgl3/build.gradle b/lwjgl3/build.gradle new file mode 100644 index 0000000..6a3c6d2 --- /dev/null +++ b/lwjgl3/build.gradle @@ -0,0 +1,185 @@ + +buildscript { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } + dependencies { + classpath "io.github.fourlastor:construo:2.1.0" + if(enableGraalNative == 'true') { + classpath "org.graalvm.buildtools.native:org.graalvm.buildtools.native.gradle.plugin:0.9.28" + } + } +} +plugins { + id "application" +} +apply plugin: 'io.github.fourlastor.construo' + + +import io.github.fourlastor.construo.Target + +sourceSets.main.resources.srcDirs += [ rootProject.file('assets').path ] +application.mainClass = 'com.group14.regicidechess.lwjgl3.Lwjgl3Launcher' +eclipse.project.name = appName + '-lwjgl3' +java.sourceCompatibility = 8 +java.targetCompatibility = 8 +if (JavaVersion.current().isJava9Compatible()) { + compileJava.options.release.set(8) +} + +dependencies { + implementation "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion" + implementation "com.badlogicgames.gdx:gdx-lwjgl3-angle:$gdxVersion" + implementation "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" + implementation project(':core') + + implementation 'com.google.firebase:firebase-admin:9.4.3' + implementation 'org.slf4j:slf4j-simple:2.0.17' // firebase auth + + if(enableGraalNative == 'true') { + implementation "io.github.berstanio:gdx-svmhelper-backend-lwjgl3:$graalHelperVersion" + + } +} + +def os = System.properties['os.name'].toLowerCase(Locale.ROOT) + +run { + workingDir = rootProject.file('assets').path +// You can uncomment the next line if your IDE claims a build failure even when the app closed properly. + //setIgnoreExitValue(true) + + if (os.contains('mac')) jvmArgs += "-XstartOnFirstThread" +} + +jar { +// sets the name of the .jar file this produces to the name of the game or app, with the version after. + archiveFileName.set("${appName}-${projectVersion}.jar") +// the duplicatesStrategy matters starting in Gradle 7.0; this setting works. + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + dependsOn configurations.runtimeClasspath + from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } } +// these "exclude" lines remove some unnecessary duplicate files in the output JAR. + exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + exclude('META-INF/INDEX.LIST', 'META-INF/maven/**') + } +// setting the manifest makes the JAR runnable. +// enabling native access helps avoid a warning when Java 24 or later runs the JAR. + manifest { + attributes 'Main-Class': application.mainClass, 'Enable-Native-Access': 'ALL-UNNAMED' + } +// this last step may help on some OSes that need extra instruction to make runnable JARs. + doLast { + file(archiveFile).setExecutable(true, false) + } +} + +// Builds a JAR that only includes the files needed to run on macOS, not Windows or Linux. +// The file size for a Mac-only JAR is about 7MB smaller than a cross-platform JAR. +tasks.register("jarMac") { + dependsOn("jar") + group("build") + jar.archiveFileName.set("${appName}-${projectVersion}-mac.jar") + jar.exclude("windows/x86/**", "windows/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", "**/*.dll", "**/*.so", + 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + jar.exclude("windows/x86/**", "windows/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", + 'META-INF/INDEX.LIST', 'META-INF/maven/**') + } +} + +// Builds a JAR that only includes the files needed to run on Linux, not Windows or macOS. +// The file size for a Linux-only JAR is about 5MB smaller than a cross-platform JAR. +tasks.register("jarLinux") { + dependsOn("jar") + group("build") + jar.archiveFileName.set("${appName}-${projectVersion}-linux.jar") + jar.exclude("windows/x86/**", "windows/x64/**", "macos/arm64/**", "macos/x64/**", "**/*.dll", "**/*.dylib", + 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + jar.exclude("windows/x86/**", "windows/x64/**", "macos/arm64/**", "macos/x64/**", + 'META-INF/INDEX.LIST', 'META-INF/maven/**') + } +} + +// Builds a JAR that only includes the files needed to run on Windows, not Linux or macOS. +// The file size for a Windows-only JAR is about 6MB smaller than a cross-platform JAR. +tasks.register("jarWin") { + dependsOn("jar") + group("build") + jar.archiveFileName.set("${appName}-${projectVersion}-win.jar") + jar.exclude("macos/arm64/**", "macos/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", "**/*.dylib", "**/*.so", + 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + jar.exclude("macos/arm64/**", "macos/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", + 'META-INF/INDEX.LIST', 'META-INF/maven/**') + } +} + +construo { + // name of the executable + name.set(appName) + // human-readable name, used for example in the `.app` name for macOS + humanName.set(appName) + + targets.configure { + register("linuxX64", Target.Linux) { + architecture.set(Target.Architecture.X86_64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_x64_linux_hotspot_17.0.15_6.tar.gz") + // Linux does not currently have a way to set the icon on the executable + } + register("macM1", Target.MacOs) { + architecture.set(Target.Architecture.AARCH64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.15_6.tar.gz") + // macOS needs an identifier + identifier.set("com.group14.regicidechess." + appName) + // Optional: icon for macOS, as an ICNS file + macIcon.set(project.file("icons/logo.icns")) + } + register("macX64", Target.MacOs) { + architecture.set(Target.Architecture.X86_64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_x64_mac_hotspot_17.0.15_6.tar.gz") + // macOS needs an identifier + identifier.set("com.group14.regicidechess." + appName) + // Optional: icon for macOS, as an ICNS file + macIcon.set(project.file("icons/logo.icns")) + } + register("winX64", Target.Windows) { + architecture.set(Target.Architecture.X86_64) + // Optional: icon for Windows, as a PNG + icon.set(project.file("icons/logo.png")) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_x64_windows_hotspot_17.0.15_6.zip") + // Uncomment the next line to show a console when the game runs, to print messages. + //useConsole.set(true) + } + } +} + +// Equivalent to the jar task; here for compatibility with gdx-setup. +tasks.register('dist') { + dependsOn 'jar' +} + +distributions { + main { + contents { + into('libs') { + project.configurations.runtimeClasspath.files.findAll { file -> + file.getName() != project.tasks.jar.outputs.files.singleFile.name + }.each { file -> + exclude file.name + } + } + } + } +} + +startScripts.dependsOn(':lwjgl3:jar') +startScripts.classpath = project.tasks.jar.outputs.files + +if(enableGraalNative == 'true') { + apply from: file("nativeimage.gradle") +} diff --git a/lwjgl3/icons/logo.icns b/lwjgl3/icons/logo.icns new file mode 100644 index 0000000..5e41ad7 Binary files /dev/null and b/lwjgl3/icons/logo.icns differ diff --git a/lwjgl3/icons/logo.ico b/lwjgl3/icons/logo.ico new file mode 100644 index 0000000..c4f2d5e Binary files /dev/null and b/lwjgl3/icons/logo.ico differ diff --git a/lwjgl3/icons/logo.png b/lwjgl3/icons/logo.png new file mode 100644 index 0000000..f810616 Binary files /dev/null and b/lwjgl3/icons/logo.png differ diff --git a/lwjgl3/nativeimage.gradle b/lwjgl3/nativeimage.gradle new file mode 100644 index 0000000..bee3fd2 --- /dev/null +++ b/lwjgl3/nativeimage.gradle @@ -0,0 +1,54 @@ + +project(":lwjgl3") { + apply plugin: "org.graalvm.buildtools.native" + + graalvmNative { + binaries { + main { + imageName = appName + mainClass = application.mainClass + requiredVersion = '23.0' + buildArgs.add("-march=compatibility") + jvmArgs.addAll("-Dfile.encoding=UTF8") + sharedLibrary = false + resources.autodetect() + } + } + } + + run { + doNotTrackState("Running the app should not be affected by Graal.") + } + + // Modified from https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/ ; thanks again, Lyze! + // This creates a resource-config.json file based on the contents of the assets folder (and the libGDX icons). + // This file is used by Graal Native to embed those specific files. + // This has to run before nativeCompile, so it runs at the start of an unrelated resource-handling command. + generateResourcesConfigFile.doFirst { + def assetsFolder = new File("${project.rootDir}/assets/") + def lwjgl3 = project(':lwjgl3') + def resFolder = new File("${lwjgl3.projectDir}/src/main/resources/META-INF/native-image/${lwjgl3.ext.appName}") + resFolder.mkdirs() + def resFile = new File(resFolder, "resource-config.json") + resFile.delete() + resFile.append( + """{ + "resources":{ + "includes":[ + { + "pattern": ".*(""") + // This adds every filename in the assets/ folder to a pattern that adds those files as resources. + fileTree(assetsFolder).each { + // The backslash-Q and backslash-E escape the start and end of a literal string, respectively. + resFile.append("\\\\Q${it.name}\\\\E|") + } + // We also match all of the window icon images this way and the font files that are part of libGDX. + resFile.append( + """libgdx.+\\\\.png|lsans.+)" + } + ]}, + "bundles":[] +}""" + ) + } +} diff --git a/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/DesktopAPI.java b/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/DesktopAPI.java new file mode 100644 index 0000000..b431d1f --- /dev/null +++ b/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/DesktopAPI.java @@ -0,0 +1,46 @@ +package com.group14.regicidechess.lwjgl3; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.FirebaseDatabase; +import com.group14.regicidechess.API; + +import java.io.FileInputStream; +import java.io.IOException; + +public class DesktopAPI implements API { + private DatabaseReference lobbies; + + public DesktopAPI() { + try { + FirebaseOptions.Builder builder = FirebaseOptions.builder() + .setDatabaseUrl("https://regicide-chess-default-rtdb.firebaseio.com"); + + try (FileInputStream serviceAccount = new FileInputStream("service-account.json")) { + builder.setCredentials(GoogleCredentials.fromStream(serviceAccount)); + } catch (IOException e) { + System.err.println("service-account.json not found"); + return; + } + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(builder.build()); + } + lobbies = FirebaseDatabase.getInstance().getReference("lobbies"); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void createLobby() { + if (lobbies != null) { + lobbies.push().setValueAsync(1); + System.out.println("Lobby created Desktop"); + } else { + System.err.println("Pourquoi frr"); + } + } +} diff --git a/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/Lwjgl3Launcher.java b/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/Lwjgl3Launcher.java new file mode 100644 index 0000000..f633b65 --- /dev/null +++ b/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/Lwjgl3Launcher.java @@ -0,0 +1,45 @@ +package com.group14.regicidechess.lwjgl3; + +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application; +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration; +import com.group14.regicidechess.Main; + +/** Launches the desktop (LWJGL3) application. */ +public class Lwjgl3Launcher { + public static void main(String[] args) { + if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows. + createApplication(); + } + + private static Lwjgl3Application createApplication() { + return new Lwjgl3Application(new Main(new DesktopAPI()), getDefaultConfiguration()); + } + + private static Lwjgl3ApplicationConfiguration getDefaultConfiguration() { + Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration(); + configuration.setTitle("regicidechess"); + //// Vsync limits the frames per second to what your hardware can display, and helps eliminate + //// screen tearing. This setting doesn't always work on Linux, so the line after is a safeguard. + configuration.useVsync(true); + //// Limits FPS to the refresh rate of the currently active monitor, plus 1 to try to match fractional + //// refresh rates. The Vsync setting above should limit the actual FPS to match the monitor. + configuration.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate + 1); + //// If you remove the above line and set Vsync to false, you can get unlimited FPS, which can be + //// useful for testing performance, but can also be very stressful to some hardware. + //// You may also need to configure GPU drivers to fully disable Vsync; this can cause screen tearing. + + configuration.setWindowedMode(640, 480); + //// You can change these files; they are in lwjgl3/src/main/resources/ . + //// They can also be loaded from the root of assets/ . + configuration.setWindowIcon("libgdx128.png", "libgdx64.png", "libgdx32.png", "libgdx16.png"); + + //// This should improve compatibility with Windows machines with buggy OpenGL drivers, Macs + //// with Apple Silicon that have to emulate compatibility with OpenGL anyway, and more. + //// This uses the dependency `com.badlogicgames.gdx:gdx-lwjgl3-angle` to function. + //// You can choose to remove the following line and the mentioned dependency if you want; they + //// are not intended for games that use GL30 (which is compatibility with OpenGL ES 3.0). + configuration.setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.ANGLE_GLES20, 0, 0); + + return configuration; + } +} diff --git a/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/StartupHelper.java b/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/StartupHelper.java new file mode 100644 index 0000000..d726a4e --- /dev/null +++ b/lwjgl3/src/main/java/com/group14/regicidechess/lwjgl3/StartupHelper.java @@ -0,0 +1,204 @@ +/* + * Copyright 2020 damios + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//Note, the above license and copyright applies to this file only. + +package com.group14.regicidechess.lwjgl3; + +import com.badlogic.gdx.Version; +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3NativesLoader; +import org.lwjgl.system.macosx.LibC; +import org.lwjgl.system.macosx.ObjCRuntime; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; + +import static org.lwjgl.system.JNI.invokePPP; +import static org.lwjgl.system.JNI.invokePPZ; +import static org.lwjgl.system.macosx.ObjCRuntime.objc_getClass; +import static org.lwjgl.system.macosx.ObjCRuntime.sel_getUid; + +/** + * Adds some utilities to ensure that the JVM was started with the + * {@code -XstartOnFirstThread} argument, which is required on macOS for LWJGL 3 + * to function. Also helps on Windows when users have names with characters from + * outside the Latin alphabet, a common cause of startup crashes. + *
+ * Based on this java-gaming.org post by kappa + * @author damios + */ +public class StartupHelper { + + private static final String JVM_RESTARTED_ARG = "jvmIsRestarted"; + + private StartupHelper() { + throw new UnsupportedOperationException(); + } + + /** + * Starts a new JVM if the application was started on macOS without the + * {@code -XstartOnFirstThread} argument. This also includes some code for + * Windows, for the case where the user's home directory includes certain + * non-Latin-alphabet characters (without this code, most LWJGL3 apps fail + * immediately for those users). Returns whether a new JVM was started and + * thus no code should be executed. + *

+ * Usage: + * + *


+     * public static void main(String... args) {
+     * 	if (StartupHelper.startNewJvmIfRequired(true)) return; // This handles macOS support and helps on Windows.
+     * 	// after this is the actual main method code
+     * }
+     * 
+ * + * @param redirectOutput + * whether the output of the new JVM should be rerouted to the + * old JVM, so it can be accessed in the same place; keeps the + * old JVM running if enabled + * @return whether a new JVM was started and thus no code should be executed + * in this one + */ + public static boolean startNewJvmIfRequired(boolean redirectOutput) { + String osName = System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT); + if (!osName.contains("mac")) { + if (osName.contains("windows")) { +// Here, we are trying to work around an issue with how LWJGL3 loads its extracted .dll files. +// By default, LWJGL3 extracts to the directory specified by "java.io.tmpdir", which is usually the user's home. +// If the user's name has non-ASCII (or some non-alphanumeric) characters in it, that would fail. +// By extracting to the relevant "ProgramData" folder, which is usually "C:\ProgramData", we avoid this. +// We also temporarily change the "user.name" property to one without any chars that would be invalid. +// We revert our changes immediately after loading LWJGL3 natives. + String programData = System.getenv("ProgramData"); + if(programData == null) programData = "C:\\Temp\\"; // if ProgramData isn't set, try some fallback. + String prevTmpDir = System.getProperty("java.io.tmpdir", programData); + String prevUser = System.getProperty("user.name", "libGDX_User"); + System.setProperty("java.io.tmpdir", programData + "/libGDX-temp"); + System.setProperty("user.name", ("User_" + prevUser.hashCode() + "_GDX" + Version.VERSION).replace('.', '_')); + Lwjgl3NativesLoader.load(); + System.setProperty("java.io.tmpdir", prevTmpDir); + System.setProperty("user.name", prevUser); + } + return false; + } + + // There is no need for -XstartOnFirstThread on Graal native image + if (!System.getProperty("org.graalvm.nativeimage.imagecode", "").isEmpty()) { + return false; + } + + // Checks if we are already on the main thread, such as from running via Construo. + long objc_msgSend = ObjCRuntime.getLibrary().getFunctionAddress("objc_msgSend"); + long NSThread = objc_getClass("NSThread"); + long currentThread = invokePPP(NSThread, sel_getUid("currentThread"), objc_msgSend); + boolean isMainThread = invokePPZ(currentThread, sel_getUid("isMainThread"), objc_msgSend); + if(isMainThread) return false; + + long pid = LibC.getpid(); + + // check whether -XstartOnFirstThread is enabled + if ("1".equals(System.getenv("JAVA_STARTED_ON_FIRST_THREAD_" + pid))) { + return false; + } + + // check whether the JVM was previously restarted + // avoids looping, but most certainly leads to a crash + if ("true".equals(System.getProperty(JVM_RESTARTED_ARG))) { + System.err.println( + "There was a problem evaluating whether the JVM was started with the -XstartOnFirstThread argument."); + return false; + } + + // Restart the JVM with -XstartOnFirstThread + ArrayList jvmArgs = new ArrayList<>(); + String separator = System.getProperty("file.separator", "/"); + // The following line is used assuming you target Java 8, the minimum for LWJGL3. + String javaExecPath = System.getProperty("java.home") + separator + "bin" + separator + "java"; + // If targeting Java 9 or higher, you could use the following instead of the above line: + //String javaExecPath = ProcessHandle.current().info().command().orElseThrow(); + + if (!(new File(javaExecPath)).exists()) { + System.err.println( + "A Java installation could not be found. If you are distributing this app with a bundled JRE, be sure to set the -XstartOnFirstThread argument manually!"); + return false; + } + + jvmArgs.add(javaExecPath); + jvmArgs.add("-XstartOnFirstThread"); + jvmArgs.add("-D" + JVM_RESTARTED_ARG + "=true"); + jvmArgs.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments()); + jvmArgs.add("-cp"); + jvmArgs.add(System.getProperty("java.class.path")); + String mainClass = System.getenv("JAVA_MAIN_CLASS_" + pid); + if (mainClass == null) { + StackTraceElement[] trace = Thread.currentThread().getStackTrace(); + if (trace.length > 0) { + mainClass = trace[trace.length - 1].getClassName(); + } else { + System.err.println("The main class could not be determined."); + return false; + } + } + jvmArgs.add(mainClass); + + try { + if (!redirectOutput) { + ProcessBuilder processBuilder = new ProcessBuilder(jvmArgs); + processBuilder.start(); + } else { + Process process = (new ProcessBuilder(jvmArgs)) + .redirectErrorStream(true).start(); + BufferedReader processOutput = new BufferedReader( + new InputStreamReader(process.getInputStream())); + String line; + + while ((line = processOutput.readLine()) != null) { + System.out.println(line); + } + + process.waitFor(); + } + } catch (Exception e) { + System.err.println("There was a problem restarting the JVM"); + e.printStackTrace(); + } + + return true; + } + + /** + * Starts a new JVM if the application was started on macOS without the + * {@code -XstartOnFirstThread} argument. Returns whether a new JVM was + * started and thus no code should be executed. Redirects the output of the + * new JVM to the old one. + *

+ * Usage: + * + *

+     * public static void main(String... args) {
+     * 	if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows.
+     * 	// the actual main method code
+     * }
+     * 
+ * + * @return whether a new JVM was started and thus no code should be executed + * in this one + */ + public static boolean startNewJvmIfRequired() { + return startNewJvmIfRequired(true); + } +} \ No newline at end of file diff --git a/lwjgl3/src/main/resources/libgdx128.png b/lwjgl3/src/main/resources/libgdx128.png new file mode 100644 index 0000000..f810616 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx128.png differ diff --git a/lwjgl3/src/main/resources/libgdx16.png b/lwjgl3/src/main/resources/libgdx16.png new file mode 100644 index 0000000..a6b1327 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx16.png differ diff --git a/lwjgl3/src/main/resources/libgdx32.png b/lwjgl3/src/main/resources/libgdx32.png new file mode 100644 index 0000000..9447b39 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx32.png differ diff --git a/lwjgl3/src/main/resources/libgdx64.png b/lwjgl3/src/main/resources/libgdx64.png new file mode 100644 index 0000000..7513f3b Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx64.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..27de380 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +plugins { + // Applies the foojay-resolver plugin to allow automatic download of JDKs. + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +// A list of which subprojects to load as part of the same larger project. +// You can remove Strings from the list and reload the Gradle project +// if you want to temporarily disable a subproject. +include 'android', 'lwjgl3', 'core'