Compare commits

...

50 Commits

Author SHA1 Message Date
TeamCity 4156e1c9ce ci: bump version to v4.6.2 2025-12-01 20:21:02 +00:00
Janis 358556612e fix: FRO-6 Websocket Close Handle (#96)
Reviewed-on: #96
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-01 21:17:58 +01:00
TeamCity 7f82d2eeae ci: bump version to v4.6.1 2025-12-01 19:44:13 +00:00
Janis a55f0b4b61 fix(api): BAC-23 Remove old polling code (#95)
Reviewed-on: #95
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-01 20:41:27 +01:00
TeamCity f115c03ecb ci: bump version to v4.6.0 2025-12-01 19:07:26 +00:00
Janis fd2467a9ea feat(api): BAC-11 Websocket - Return to Lobby (#94)
Reviewed-on: #94
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-01 20:04:17 +01:00
TeamCity 9d3f3940a9 ci: bump version to v4.5.0 2025-12-01 18:53:23 +00:00
Janis 0541bb58d1 feat(api): BAC-10 Websockets - Kick Users (#93)
Reviewed-on: #93
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-01 19:50:19 +01:00
TeamCity 89a6aa22f7 ci: bump version to v4.4.0 2025-12-01 18:18:44 +00:00
Janis 6e17328846 feat: GameState to Title Mapping BAC-1 (#92)
Reviewed-on: #92
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-01 19:13:32 +01:00
Janis 0037820905 feat(ui): Popups (#91)
Fixed sorting and added popups for trickend and roundend

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #91
2025-11-27 10:01:19 +01:00
Janis cfcd967ce0 fix(api): fixes - reimplemented animations (#90)
Reviewed-on: #90
2025-11-27 09:52:00 +01:00
Janis 1f96290371 feat(ui): Implement countless feature using the SJWP (#89)
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #89
2025-11-27 08:53:37 +01:00
Janis 2aee79bb68 feat(api): Implemented turn event via websocket (#86)
Co-authored-by: TeamCity <teamcity@service.local>
Reviewed-on: #86
Reviewed-by: lq64 <lq@blackhole.local>
2025-11-27 07:57:37 +01:00
Janis 46c96d4ceb fix(api): Fixed websocket routing (#88)
Reviewed-on: #88
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-27 07:51:02 +01:00
TeamCity 14b4473f72 ci: bump version to v4.3.0 2025-11-26 17:44:23 +00:00
Janis 1ef5e8a72f feat(api): Implemented session closed and kick event via websocket (#87)
Reviewed-on: #87
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-26 18:41:25 +01:00
TeamCity 576e5af87e ci: bump version to v4.2.0 2025-11-26 12:37:57 +00:00
Janis 3c0828fdbe feat(api): Implemented card played event via websocket (#85)
Reviewed-on: #85
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-26 13:35:05 +01:00
TeamCity ae7f04abc3 ci: bump version to v4.1.0 2025-11-26 10:29:11 +00:00
Janis b81bb3d0ae feat(base): Fixed logic for websockets and added GameStateEvent. Might've caused instability on other feature branches! (#84)
Reviewed-on: #84
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-26 11:26:08 +01:00
Janis 52e5033afc feat(api): Implement received hand event handling and UI updates (#83)
#76

Reviewed-on: #83
2025-11-24 14:31:31 +01:00
TeamCity 10a26404b3 ci: bump version to v4.0.1 2025-11-24 11:21:05 +00:00
TeamCity 11478a096d ci: bump version to v4.0.0 2025-11-23 15:15:09 +00:00
Janis 8ca909db52 feat(websocket)!: Implement WebSocket connection and event handling (#82)
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #82
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-23 16:11:46 +01:00
TeamCity 1edb3bfd89 ci: bump version to v3.0.1 2025-11-22 20:38:51 +00:00
Janis 9738a04b7a fix(api): Fixed a bug where the game would reload on game start (#81)
Reviewed-on: #81
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-22 21:36:23 +01:00
TeamCity 2bc50664e0 ci: bump version to v3.0.0 2025-11-20 15:31:03 +00:00
Janis 3e3a062a06 ci: bump version to v2.0.0 2025-11-20 16:27:39 +01:00
Janis 641c892981 fix(polling): Improve polling mechanism and delay handling (#60)
Reviewed-on: #60
2025-11-20 10:51:39 +01:00
Janis a58b2e03b1 feat(game)!: Fixed polling, SPA, Gameplayloop etc. (#59)
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #59
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-19 22:54:20 +01:00
Janis e60fe7c98d feat(ci): Polling Added polling for when the game starts and a card gets played (#58)
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #58
2025-11-14 09:11:32 +01:00
Janis 370de175db feat(ci): Polling
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #53
2025-11-13 11:07:08 +01:00
Janis 5d245d0011 feat(ui): implement tie & trump menu, fixed some critical bugs (#52)
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #52
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-13 08:20:30 +01:00
lq64 c220e54bb8 feat(ui): added js routing, updated ingame ui, added tricktable (#50)
This merge request has full JS routing for calling specific endpoints. Game is fully playable but doesn't have polling yet. This version already has the UI changes adressed in MR #43 so first merge MR #43 and then this one or only merge this one because it already has the UI changes :)

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #50
Reviewed-by: Janis <janis-e@gmx.de>
2025-11-12 11:44:21 +01:00
TeamCity b847d3c054 ci: bump version to v1.0.9 2025-11-07 16:54:36 +00:00
Janis c7dd72ecc2 fix: removed trailing 2025-11-07 17:52:12 +01:00
Janis 42a5adbae0 fix: removed trailing 2025-11-07 17:46:55 +01:00
TeamCity ae9a8f2af9 ci: bump version to v1.0.8 2025-11-07 16:28:11 +00:00
Janis 7adc8b8645 fix: trailing 2025-11-07 17:25:35 +01:00
TeamCity 146348470f ci: bump version to v1.0.7 2025-11-07 15:52:23 +00:00
Janis 5e503cbc36 fix: removed trailing 2025-11-07 16:49:51 +01:00
TeamCity 126e2030ae ci: bump version to v1.0.6 2025-11-07 15:45:22 +00:00
Janis 54e3215127 fix: traling 2025-11-07 16:42:50 +01:00
TeamCity 72d2845772 ci: bump version to v1.0.5 2025-11-07 15:00:33 +00:00
Janis 64a7a63ab3 fix: removed trailing 2025-11-07 15:56:50 +01:00
TeamCity 51c36348b9 ci: bump version to v1.0.4 2025-11-07 14:47:49 +00:00
Janis 2e54880302 fix: changelog syntax 2025-11-07 15:45:31 +01:00
TeamCity 266406fe7c ci: bump version to v1.0.3 2025-11-07 14:38:07 +00:00
Janis 5c6d3ac436 fix: ensure proper CMD syntax in Dockerfile (#48)
Reviewed-on: #48
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-07 15:24:08 +01:00
69 changed files with 4469 additions and 2242 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
-J--add-opens=java.base/java.util=ALL-UNNAMED -J--add-opens=java.base/java.util=ALL-UNNAMED
-J--add-opens=java.base/java.lang=ALL-UNNAMED -J--add-opens=java.base/java.lang=ALL-UNNAMED
+118
View File
@@ -74,3 +74,121 @@
* ci: bump version to v1.0.0 [skip ci] ([91d7f6c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/91d7f6ca003edb368aba76b7a82c8b42d22bdbfe)) * ci: bump version to v1.0.0 [skip ci] ([91d7f6c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/91d7f6ca003edb368aba76b7a82c8b42d22bdbfe))
* version bumb ([2e10059](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2e10059a6756befe6f8e70c94fd34b865693efb8)) * version bumb ([2e10059](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2e10059a6756befe6f8e70c94fd34b865693efb8))
* version bump ([7879d1a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7879d1ab6ee8f227c19b69b467a28dd7b479ff73)) * version bump ([7879d1a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7879d1ab6ee8f227c19b69b467a28dd7b479ff73))
## (2025-11-07)
### Bug Fixes
* ensure proper CMD syntax in Dockerfile ([#48](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/48)) ([5c6d3ac](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5c6d3ac436f6d23a36f58b6835c9bd50feddc789))
## (2025-11-07)
### Bug Fixes
* changelog syntax ([2e54880](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2e548803020c99f62644283fcf3570048261173a))
## (2025-11-07)
### Bug Fixes
* removed trailing ([64a7a63](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/64a7a63ab3dff59e66f62328e3b5865bb177fcde))
## (2025-11-07)
### Bug Fixes
* traling ([54e3215](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/54e321512777f6722864694eb677eab0e8418a9f))
## (2025-11-07)
### Bug Fixes
* removed trailing ([5e503cb](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5e503cbc364f7cb23926976acc6cee575eadd9d6))
## (2025-11-07)
### Bug Fixes
* trailing ([7adc8b8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7adc8b8645390cd18d63b4eee6db8ef448b7a46a))
## (2025-11-07)
### Bug Fixes
* removed trailing ([c7dd72e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c7dd72ecc2786ef63daf2b4288093025a8e22bfd))
* removed trailing ([42a5adb](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/42a5adbae01802587a48a9de15bad44b5ef014cf))
## (2025-11-20)
### ⚠ BREAKING CHANGES
* **game:** Fixed polling, SPA, Gameplayloop etc. (#59)
### Features
* **ci:** Polling ([370de17](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/370de175db65edf87e7ab211190977b540a39d85))
* **ci:** Polling Added polling for when the game starts and a card gets played ([#58](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/58)) ([e60fe7c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e60fe7c98dcab05949140a8a54ed6e4e2fbbc022))
* **game:** Fixed polling, SPA, Gameplayloop etc. ([#59](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/59)) ([a58b2e0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/a58b2e03b11a54667d63ba6604f579a8e328c9d1))
* **ui:** added js routing, updated ingame ui, added tricktable ([#50](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/50)) ([c220e54](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c220e54bb8d87f4f0f37a089bcd993e8df806123)), closes [#43](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/43) [#43](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/43)
* **ui:** implement tie & trump menu, fixed some critical bugs ([#52](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/52)) ([5d245d0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5d245d0011a5fb03193514303b45702cd8329224))
### Bug Fixes
* **polling:** Improve polling mechanism and delay handling ([#60](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/60)) ([641c892](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/641c892981649eb85640527cc0fe325ff683fa77))
## (2025-11-22)
### Bug Fixes
* **api:** Fixed a bug where the game would reload on game start ([#81](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/81)) ([9738a04](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/9738a04b7a3c63c8cd1450e563ec04823fb3c35a))
## (2025-11-23)
### ⚠ BREAKING CHANGES
* **websocket:** Implement WebSocket connection and event handling (#82)
### Features
* **websocket:** Implement WebSocket connection and event handling ([#82](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/82)) ([8ca909d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8ca909db522dd7108a3e40ce84811eaf8695eaa5))
## (2025-11-24)
## (2025-11-26)
### Features
* **api:** Implement received hand event handling and UI updates ([#83](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/83)) ([52e5033](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/52e5033afca344ae40a644196555a9655913710a)), closes [#76](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/76)
* **base:** Fixed logic for websockets and added GameStateEvent. Might've caused instability on other feature branches! ([#84](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/84)) ([b81bb3d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b81bb3d0aeb8500a9d7417a10e24e7f8a17d71d2))
## (2025-11-26)
### Features
* **api:** Implemented card played event via websocket ([#85](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/85)) ([3c0828f](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/3c0828fdbeb507706b86f1662476c46e760533e4))
## (2025-11-26)
### Features
* **api:** Implemented session closed and kick event via websocket ([#87](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/87)) ([1ef5e8a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1ef5e8a72fdf8a3d1ae624c8c3d7c6595017bc6f))
## (2025-12-01)
### Features
* **api:** Implemented turn event via websocket ([#86](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/86)) ([2aee79b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2aee79bb6887008397aa0780d1d74ce96af1c202))
* GameState to Title Mapping BAC-1 ([#92](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/92)) ([6e17328](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/6e17328846745375482c97383b143d86a86e7f32))
* **ui:** Implement countless feature using the SJWP ([#89](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/89)) ([1f96290](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1f962903712163543fd4f98e696be5e7e29d88a6))
* **ui:** Popups ([#91](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/91)) ([0037820](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/003782090509bca1c5022c308231b7560dd9b23d))
### Bug Fixes
* **api:** Fixed websocket routing ([#88](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/88)) ([46c96d4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/46c96d4ceb935ac91fc515a1fdaef195e5ebc0a7))
* **api:** fixes - reimplemented animations ([#90](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/90)) ([cfcd967](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/cfcd967ce08ecf07f3f06826c337f684eb3b0c5f))
## (2025-12-01)
### Features
* **api:** BAC-10 Websockets - Kick Users ([#93](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/93)) ([0541bb5](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/0541bb58d19efd98d134b3d0412f39b4b1001783))
## (2025-12-01)
### Features
* **api:** BAC-11 Websocket - Return to Lobby ([#94](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/94)) ([fd2467a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/fd2467a9ea22dca64d5152a5a3e6db86d9a6f345))
## (2025-12-01)
### Bug Fixes
* **api:** BAC-23 Remove old polling code ([#95](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/95)) ([a55f0b4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/a55f0b4b6164a47e3524422650ed99d10f9c8b0d))
## (2025-12-01)
### Bug Fixes
* FRO-6 Websocket Close Handle ([#96](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/96)) ([3585566](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/358556612ec74601c8b31125e4e65f750abf8c4c))
+1 -1
View File
@@ -38,4 +38,4 @@ ENV PLAY_HTTP_PORT=9000
# Run the Play app # Run the Play app
ENTRYPOINT ["./bin/knockoutwhistweb"] ENTRYPOINT ["./bin/knockoutwhistweb"]
CMD ["-Dplay.server.pidfile.path=/dev/null"] CMD ["-Dplay.server.pidfile.path=/dev/null"]
+9 -8
View File
@@ -1,12 +1,12 @@
ThisBuild / scalaVersion := "3.5.1" ThisBuild / scalaVersion := "3.5.1"
lazy val commonSettings = Seq( lazy val commonSettings = Seq(
libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.18", libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19",
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % "test", libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test",
libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.0.1", libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.1.0",
libraryDependencies += "org.scalafx" %% "scalafx" % "22.0.0-R33", libraryDependencies += "org.scalafx" %% "scalafx" % "24.0.2-R36",
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.3.0", libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.4.0",
libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M1", libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M9",
libraryDependencies ++= { libraryDependencies ++= {
// Determine OS version of JavaFX binaries // Determine OS version of JavaFX binaries
lazy val osName = System.getProperty("os.name") match { lazy val osName = System.getProperty("os.name") match {
@@ -38,8 +38,9 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
commonSettings, commonSettings,
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test,
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0", libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0",
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2", libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
) )
+1 -1
View File
@@ -113,4 +113,4 @@
"description": "The commit references another commit by its hash ID.<br>For multiple hash IDs, use a comma as separator" "description": "The commit references another commit by its hash ID.<br>For multiple hash IDs, use a comma as separator"
} }
} }
} }
@@ -3,7 +3,7 @@
--background-image: url('/assets/images/background.png') !important; --background-image: url('/assets/images/background.png') !important;
--color: #f8f9fa !important; /* Light text on dark bg */ --color: #f8f9fa !important; /* Light text on dark bg */
--highlightscolor: rgba(131, 131, 131, 0.75) !important; --highlightscolor: rgba(131, 131, 131, 0.75) !important;
--background-color: #192734;
/* Bootstrap variable overrides for dark mode */ /* Bootstrap variable overrides for dark mode */
--bs-body-color: var(--color); --bs-body-color: var(--color);
--bs-link-color: #66b2ff; --bs-link-color: #66b2ff;
@@ -2,4 +2,5 @@
--background-image: url('/assets/images/img.png'); --background-image: url('/assets/images/img.png');
--color: black; --color: black;
--highlightscolor: rgba(0, 0, 0, 0.75); --highlightscolor: rgba(0, 0, 0, 0.75);
--background-color: rgba(228, 232, 237, 1);
} }
@@ -17,7 +17,7 @@
width: 100%; width: 100%;
border: none; border: none;
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
z-index: 3; /* ensure card sits above the particles */ z-index: 3; /* ensure card sits above the particles */
} }
+213 -125
View File
@@ -15,194 +15,282 @@
--bs-heading-color: var(--color) !important; --bs-heading-color: var(--color) !important;
} }
@background-color: var(--background-color);
@highlightcolor: var(--highlightscolor); @highlightcolor: var(--highlightscolor);
@background-image: var(--background-image); @background-image: var(--background-image);
@color: var(--color); @color: var(--color);
@keyframes slideIn { @keyframes slideIn {
0% { transform: translateX(-100vw); } 0% {
100% { transform: translateX(0); } transform: translateX(-100vw);
} }
.game-field-background { 100% {
background-image: @background-image; transform: translateX(0);
background-size: cover; }
background-position: center center;
background-repeat: no-repeat;
background-attachment: fixed;
} }
.navbar-header{ .game-field-background {
text-align:center; background-image: @background-image;
background-repeat: no-repeat;
background-size: cover;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
}
.lobby-background {
background-color: @background-color;
width: 100%;
height: 100vh;
}
.navbar-header {
text-align: center;
} }
.navbar-toggle { .navbar-toggle {
float: none; float: none;
margin-right:0; margin-right: 0;
} }
.handcard :hover { .handcard :hover {
box-shadow: 3px 3px 3px @highlightcolor; box-shadow: 3px 3px 3px @highlightcolor;
} }
.inactive::after {
content: "";
position: absolute;
inset: 0; /* cover the whole container */
background: rgba(0, 0, 0, 0.50);
z-index: 10;
border-radius: 6px;
pointer-events: none; /* user can't click through overlay */
}
.bottom-div { .bottom-div {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 50%;
transform: translateX(-50%);
max-width: 1400px;
width: 100%; width: 100%;
margin: 0;
text-align: center; text-align: center;
padding: 10px; padding: 10px;
} }
/* Ensure body text color follows theme variable and works with Bootstrap */ /* Ensure body text color follows theme variable and works with Bootstrap */
body { body {
color: @color; color: @color;
} }
.footer { .footer {
width: 100%; width: 100%;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
color: @color; color: @color;
padding: 0.5rem 0; padding: 0.5rem 0;
flex-grow: 1; /* fill remaining vertical space as visual footer background */ flex-grow: 1; /* fill remaining vertical space as visual footer background */
} }
.game-field { .game-field {
position: fixed; position: fixed;
inset: 0; inset: 0;
overflow: auto; overflow: auto;
}
.navbar-drop-shadow {
box-shadow: 0 1px 15px 0 #000000
}
.ingame-side-shadow {
box-shadow: 0 1px 15px 0 #000000
} }
#sessions { #sessions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
text-align: center; text-align: center;
h1 {
animation: slideIn 0.5s ease-out forwards; h1 {
animation-fill-mode: backwards; animation: slideIn 0.5s ease-out forwards;
} animation-fill-mode: backwards;
}
} }
#textanimation { #textanimation {
animation: slideIn 0.5s ease-out forwards; animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards; animation-fill-mode: backwards;
animation-delay: 1s; animation-delay: 1s;
} }
#sessions a, #sessions h1, #sessions p { #sessions a, #sessions h1, #sessions p {
color: @color; color: @color;
font-size: 40px; font-size: 40px;
font-family: Arial, serif; font-family: Arial, serif;
} }
#ingame { #ingame {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
height: 100%; height: 100%;
} }
#ingame a, #ingame h1, #ingame p { #ingame a, #ingame h1, #ingame p {
color: @color; color: @color;
font-size: 40px; font-size: 40px;
font-family: Arial, serif; font-family: Arial, serif;
} }
#playercards {
display: flex; .ingame-cards-slide {
flex-direction: row; div {
justify-content: center; animation: slideIn 0.5s ease-out forwards;
height: 20%; animation-fill-mode: backwards;
img {
animation: slideIn 0.5s ease-out forwards; &:nth-child(1) {
animation-fill-mode: backwards; animation-delay: 0.5s;
&:nth-child(1) { animation-delay: 0.5s; }
&:nth-child(2) { animation-delay: 1s; }
&:nth-child(3) { animation-delay: 1.5s; }
&:nth-child(4) { animation-delay: 2s; }
&:nth-child(5) { animation-delay: 2.5s; }
&:nth-child(6) { animation-delay: 3s; }
&:nth-child(7) { animation-delay: 3.5s; }
}
}
#card-slide {
div {
animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards;
&:nth-child(1) { animation-delay: 0.5s; }
&:nth-child(2) { animation-delay: 1s; }
&:nth-child(3) { animation-delay: 1.5s; }
&:nth-child(4) { animation-delay: 2s; }
&:nth-child(5) { animation-delay: 2.5s; }
&:nth-child(6) { animation-delay: 3s; }
&:nth-child(7) { animation-delay: 3.5s; }
} }
&:nth-child(2) {
animation-delay: 1s;
}
&:nth-child(3) {
animation-delay: 1.5s;
}
&:nth-child(4) {
animation-delay: 2s;
}
&:nth-child(5) {
animation-delay: 2.5s;
}
&:nth-child(6) {
animation-delay: 3s;
}
&:nth-child(7) {
animation-delay: 3.5s;
}
}
} }
#cardsplayed {
display: flex;
flex-direction: row;
height: 10%;
min-height: 10%
}
#playedcardplayer { #playedcardplayer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }
#playedcardplayer p { #playedcardplayer p {
font-size: 12px; font-size: 12px;
height: 4%; height: 4%;
} }
#playedcardplayer img { #playedcardplayer img {
height: 90%; height: 90%;
} }
#firstCard { #firstCard {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 20%; height: 20%;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
} }
#firstCardObject { #firstCardObject {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-right: 4%; margin-right: 4%;
} }
#firstCardObject img{
height: 90%; #firstCardObject img {
height: 90%;
} }
#firstCardObject p{
height: 10%; #firstCardObject p {
font-size: 20px; height: 10%;
font-size: 20px;
} }
#trumpsuit {
display: flex; #next-players-container {
flex-direction: row; display: flex;
margin-left: 4%; flex-direction: column;
} align-items: flex-start;
#nextPlayers { height: 0;
display: flex;
flex-direction: column; p {
align-items: center; margin-top: 0;
height: 0; margin-bottom: 0;
p { }
margin-top: 0;
margin-bottom: 0;
}
}
#invisible {
visibility: hidden;
} }
#selecttrumpsuit { #selecttrumpsuit {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: 100%; height: 100%;
} }
#rules { #rules {
color: @color; color: @color;
font-size: 1.5em; font-size: 1.5em;
font-family: Arial, serif; font-family: Arial, serif;
} }
.score-table {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 10px;
margin-bottom: 20px;
backdrop-filter: blur(8px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.score-header {
font-weight: bold;
color: #000000;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
.score-row {
color: #000000;
}
/* In-game centered stage and blurred sides overlay */
.ingame-stage {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
/* Wrapper that adds a backdrop blur to the background outside the centered card */
.blur-sides {
position: relative;
}
/* Create an overlay that blurs everything behind it, except the central content area */
.blur-sides::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
/* fallback: subtle vignette if backdrop-filter unsupported */
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0.35) 100%);
}
@supports ((-webkit-backdrop-filter: blur(8px)) or (backdrop-filter: blur(8px))) {
.blur-sides::before {
background: rgba(0, 0, 0, 0.08);
-webkit-backdrop-filter: blur(10px) saturate(110%);
backdrop-filter: blur(10px) saturate(110%);
}
}
+7 -7
View File
@@ -15,13 +15,6 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP
override def executionContext: ExecutionContext = ec override def executionContext: ExecutionContext = ec
protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
override def invokeBlock[A]( override def invokeBlock[A](
request: Request[A], request: Request[A],
block: AuthenticatedRequest[A] => Future[Result] block: AuthenticatedRequest[A] => Future[Result]
@@ -33,5 +26,12 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP
Future.successful(Results.Redirect(routes.UserController.login())) Future.successful(Results.Redirect(routes.UserController.login()))
} }
} }
protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
} }
@@ -2,11 +2,13 @@ package components
import de.knockoutwhist.components.DefaultConfiguration import de.knockoutwhist.components.DefaultConfiguration
import de.knockoutwhist.ui.UI import de.knockoutwhist.ui.UI
import de.knockoutwhist.utils.DelayHandler
import de.knockoutwhist.utils.events.EventListener import de.knockoutwhist.utils.events.EventListener
class WebApplicationConfiguration extends DefaultConfiguration { class WebApplicationConfiguration extends DefaultConfiguration {
override def uis: Set[UI] = Set() override def uis: Set[UI] = Set()
override def listener: Set[EventListener] = Set()
override def listener: Set[EventListener] = Set(DelayHandler)
} }
@@ -1,60 +1,50 @@
package controllers package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import de.knockoutwhist.control.GameState
import exceptions.{CantPlayCardException, GameFullException, NotEnoughPlayersException, NotHostException, NotInThisGameException} import de.knockoutwhist.control.GameState.*
import exceptions.*
import logic.PodManager import logic.PodManager
import model.sessions.{PlayerSession, UserSession} import logic.game.GameLobby
import model.sessions.UserSession
import model.users.User
import play.api.* import play.api.*
import play.api.libs.json.{JsValue, Json}
import play.api.mvc.* import play.api.mvc.*
import play.twirl.api.Html
import util.GameUtil
import java.util.UUID import java.util.UUID
import javax.inject.* import javax.inject.*
import scala.concurrent.ExecutionContext
import scala.util.Try import scala.util.Try
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton @Singleton
class IngameController @Inject()( class IngameController @Inject()(
val controllerComponents: ControllerComponents, val cc: ControllerComponents,
val authAction: AuthAction, val authAction: AuthAction,
val podManager: PodManager implicit val ec: ExecutionContext
) extends BaseController { ) extends AbstractController(cc) {
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
g.logic.getCurrentState match { val results = Try {
case Lobby => Ok(views.html.lobby.lobby(Some(request.user), g)) IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user)
case InGame => }
Ok(views.html.ingame.ingame( if (results.isSuccess) {
g.getPlayerByUser(request.user), Ok(views.html.main("Knockout Whist - " + GameUtil.stateToTitle(g.logic.getCurrentState))(results.get))
g } else {
)) InternalServerError(results.failed.get.getMessage)
case SelectTrump =>
Ok(views.html.ingame.selecttrump(
g.getPlayerByUser(request.user),
g.logic
))
case TieBreak =>
Ok(views.html.ingame.tie(
g.getPlayerByUser(request.user),
g.logic
))
case _ =>
InternalServerError(s"Invalid game state for in-game view. GameId: $gameId" + s" State: ${g.logic.getCurrentState}")
} }
case None => case None =>
NotFound("Game not found") Redirect(routes.MainMenuController.mainMenu())
} }
//NotFound(s"Reached end of game method unexpectedly. GameId: $gameId")
} }
def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
val result = Try { val result = Try {
game match { game match {
case Some(g) => case Some(g) =>
@@ -64,62 +54,84 @@ class IngameController @Inject()(
} }
} }
if (result.isSuccess) { if (result.isSuccess) {
Redirect(routes.IngameController.game(gameId)) Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url,
"content" -> IngameController.returnInnerHTML(game.get, game.get.logic.getCurrentState, request.user).toString()
))
} else { } else {
val throwable = result.failed.get val throwable = result.failed.get
throwable match { throwable match {
case _: NotInThisGameException => case _: NotInThisGameException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotHostException => case _: NotHostException =>
Forbidden(throwable.getMessage) Forbidden(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotEnoughPlayersException => case _: NotEnoughPlayersException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ => case _ =>
InternalServerError(throwable.getMessage) InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
} }
} }
} }
def kickPlayer(gameId: String, playerToKick: UUID): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
game.get.leaveGame(playerToKick) val game = PodManager.getGame(gameId)
Redirect(routes.IngameController.game(gameId)) val playerToKickUUID = UUID.fromString(playerToKick)
}
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
game.get.leaveGame(request.user.id)
Redirect(routes.MainMenuController.mainMenu())
}
def joinGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val result = Try { val result = Try {
game match { game.get.leaveGame(playerToKickUUID, true)
case Some(g) =>
g.addUser(request.user)
case None =>
NotFound("Game not found")
}
} }
if (result.isSuccess) { if (result.isSuccess) {
Redirect(routes.IngameController.game(gameId)) Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url
))
} else { } else {
val throwable = result.failed.get InternalServerError(Json.obj(
throwable match { "status" -> "failure",
case _: GameFullException => "errorMessage" -> "Something went wrong."
BadRequest(throwable.getMessage) ))
case _: IllegalArgumentException =>
BadRequest(throwable.getMessage)
case _: IllegalStateException =>
BadRequest(throwable.getMessage)
case _ =>
InternalServerError(throwable.getMessage)
}
} }
} }
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
val result = Try {
game.get.leaveGame(request.user.id, false)
}
if (result.isSuccess) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
))
} else {
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> "Something went wrong."
))
}
}
def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) val jsonBody = request.body.asJson
val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "cardID").asOpt[String]
}
cardIdOpt match { cardIdOpt match {
case Some(cardId) => case Some(cardId) =>
var optSession: Option[UserSession] = None var optSession: Option[UserSession] = None
@@ -131,35 +143,67 @@ class IngameController @Inject()(
} }
optSession.foreach(_.lock.unlock()) optSession.foreach(_.lock.unlock())
if (result.isSuccess) { if (result.isSuccess) {
NoContent Ok(Json.obj(
"status" -> "success"
))
} else { } else {
val throwable = result.failed.get val throwable = result.failed.get
throwable match { throwable match {
case _: CantPlayCardException => case _: CantPlayCardException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException => case _: NotInThisGameException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalArgumentException => case _: IllegalArgumentException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException => case _: IllegalStateException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInteractableException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ => case _ =>
InternalServerError(throwable.getMessage) InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
} }
} }
case None => case None =>
BadRequest("cardId parameter is missing") BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "cardId Parameter is missing"
))
} }
case None => case None =>
NotFound("Game not found") NotFound(Json.obj(
"status" -> "failure",
"errorMessage" -> "Game not found"
))
} }
} }
} }
def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => { case Some(g) => {
val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) val jsonBody = request.body.asJson
val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "cardID").asOpt[String]
}
var optSession: Option[UserSession] = None var optSession: Option[UserSession] = None
val result = Try { val result = Try {
cardIdOpt match { cardIdOpt match {
@@ -184,15 +228,30 @@ class IngameController @Inject()(
val throwable = result.failed.get val throwable = result.failed.get
throwable match { throwable match {
case _: CantPlayCardException => case _: CantPlayCardException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException => case _: NotInThisGameException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalArgumentException => case _: IllegalArgumentException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException => case _: IllegalStateException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ => case _ =>
InternalServerError(throwable.getMessage) InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
} }
} }
} }
@@ -201,11 +260,15 @@ class IngameController @Inject()(
} }
} }
} }
def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val trumpOpt = request.body.asFormUrlEncoded.flatMap(_.get("trump").flatMap(_.headOption)) val jsonBody = request.body.asJson
val trumpOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "trump").asOpt[String]
}
trumpOpt match { trumpOpt match {
case Some(trump) => case Some(trump) =>
var optSession: Option[UserSession] = None var optSession: Option[UserSession] = None
@@ -222,13 +285,25 @@ class IngameController @Inject()(
val throwable = result.failed.get val throwable = result.failed.get
throwable match { throwable match {
case _: IllegalArgumentException => case _: IllegalArgumentException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException => case _: NotInThisGameException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException => case _: IllegalStateException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ => case _ =>
InternalServerError(throwable.getMessage) InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
} }
} }
case None => case None =>
@@ -238,11 +313,15 @@ class IngameController @Inject()(
NotFound("Game not found") NotFound("Game not found")
} }
} }
def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val tieOpt = request.body.asFormUrlEncoded.flatMap(_.get("tie").flatMap(_.headOption)) val jsonBody = request.body.asJson
val tieOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "tie").asOpt[String]
}
tieOpt match { tieOpt match {
case Some(tie) => case Some(tie) =>
var optSession: Option[UserSession] = None var optSession: Option[UserSession] = None
@@ -259,13 +338,25 @@ class IngameController @Inject()(
val throwable = result.failed.get val throwable = result.failed.get
throwable match { throwable match {
case _: IllegalArgumentException => case _: IllegalArgumentException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException => case _: NotInThisGameException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException => case _: IllegalStateException =>
BadRequest(throwable.getMessage) BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ => case _ =>
InternalServerError(throwable.getMessage) InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
} }
} }
case None => case None =>
@@ -276,4 +367,78 @@ class IngameController @Inject()(
} }
} }
}
def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val result = Try {
val session = g.getUserSession(request.user.id)
g.returnToLobby(session)
}
if (result.isSuccess) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url
))
} else {
val throwable = result.failed.get
throwable match {
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
}
}
case None =>
NotFound(Json.obj(
"status" -> "failure",
"errorMessage" -> "Game not found"
))
}
}
}
object IngameController {
def returnInnerHTML(gameLobby: GameLobby, gameState: GameState, user: User): Html = {
gameState match {
case Lobby => views.html.lobby.lobby(Some(user), gameLobby)
case InGame =>
views.html.ingame.ingame(
gameLobby.getPlayerByUser(user),
gameLobby
)
case SelectTrump =>
views.html.ingame.selecttrump(
gameLobby.getPlayerByUser(user),
gameLobby
)
case TieBreak =>
views.html.ingame.tie(
gameLobby.getPlayerByUser(user),
gameLobby
)
case FinishedMatch =>
views.html.ingame.finishedMatch(
Some(user),
gameLobby
)
case _ =>
throw new IllegalStateException(s"Invalid game state for in-game view. GameId: ${gameLobby.id}" + s" State: ${gameLobby.logic.getCurrentState}")
}
}
}
@@ -0,0 +1,24 @@
package controllers
import auth.AuthAction
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
import play.api.routing.JavaScriptReverseRouter
import javax.inject.Inject
class JavaScriptRoutingController @Inject()(
val controllerComponents: ControllerComponents,
val authAction: AuthAction,
) extends BaseController {
def javascriptRoutes(): Action[AnyContent] =
Action { implicit request =>
Ok(
JavaScriptReverseRouter("jsRoutes")(
routes.javascript.MainMenuController.createGame,
routes.javascript.MainMenuController.joinGame,
routes.javascript.MainMenuController.navSPA,
routes.javascript.UserController.login_Post
)
).as("text/javascript")
}
}
@@ -3,6 +3,7 @@ package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import logic.PodManager import logic.PodManager
import play.api.* import play.api.*
import play.api.libs.json.Json
import play.api.mvc.* import play.api.mvc.*
import javax.inject.* import javax.inject.*
@@ -15,13 +16,12 @@ import javax.inject.*
@Singleton @Singleton
class MainMenuController @Inject()( class MainMenuController @Inject()(
val controllerComponents: ControllerComponents, val controllerComponents: ControllerComponents,
val authAction: AuthAction, val authAction: AuthAction
val podManager: PodManager
) extends BaseController { ) extends BaseController {
// Pass the request-handling function directly to authAction (no nested Action) // Pass the request-handling function directly to authAction (no nested Action)
def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Ok(views.html.mainmenu.creategame(Some(request.user))) Ok(views.html.main("Knockout Whist - Create Game")(views.html.mainmenu.creategame(Some(request.user))))
} }
def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
@@ -29,39 +29,86 @@ class MainMenuController @Inject()(
} }
def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val postData = request.body.asFormUrlEncoded val jsonBody = request.body.asJson
if (postData.isDefined) { if (jsonBody.isDefined) {
val gamename = postData.get.get("lobbyname").flatMap(_.headOption).getOrElse(s"${request.user.name}'s Game") val gamename: String = (jsonBody.get \ "lobbyname").asOpt[String]
val playeramount = postData.get.get("playeramount").flatMap(_.headOption).getOrElse("") .getOrElse(s"${request.user.name}'s Game")
val gameLobby = podManager.createGame(
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
.getOrElse(throw new IllegalArgumentException("Player amount is required."))
val gameLobby = PodManager.createGame(
host = request.user, host = request.user,
name = gamename, name = gamename,
maxPlayers = playeramount.toInt maxPlayers = playeramount.toInt
) )
Redirect(routes.IngameController.game(gameLobby.id)) Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameLobby.id).url,
"content" -> IngameController.returnInnerHTML(gameLobby, gameLobby.logic.getCurrentState, request.user).toString
))
} else { } else {
BadRequest("Invalid form submission") BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "Invalid form submission"
))
} }
} }
def joinGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def joinGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val postData = request.body.asFormUrlEncoded val jsonBody = request.body.asJson
if (postData.isDefined) { val gameId: Option[String] = jsonBody.flatMap { jsValue =>
val gameId = postData.get.get("gameId").flatMap(_.headOption).getOrElse("") (jsValue \ "gameId").asOpt[String]
val game = podManager.getGame(gameId) }
if (gameId.isDefined) {
val game = PodManager.getGame(gameId.get)
game match { game match {
case Some(g) => case Some(g) =>
Redirect(routes.IngameController.joinGame(gameId)) g.addUser(request.user)
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(g.id).url,
"content" -> IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user).toString
))
case None => case None =>
NotFound("Game not found") NotFound(Json.obj(
"status" -> "failure",
"errorMessage" -> "No Game found"
))
} }
} else { } else {
BadRequest("Invalid form submission") BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "Invalid form submission"
))
} }
} }
def rules(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def rules(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Ok(views.html.mainmenu.rules(Some(request.user))) Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user))))
} }
def navSPA(location: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
location match {
case "0" => // Main Menu
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
))
case "1" => // Rules
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.rules().url,
"content" -> views.html.mainmenu.rules(Some(request.user)).toString
))
case _ =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "Invalid form submission"
))
}
}
} }
@@ -3,6 +3,7 @@ package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import logic.user.{SessionManager, UserManager} import logic.user.{SessionManager, UserManager}
import play.api.* import play.api.*
import play.api.libs.json.Json
import play.api.mvc.* import play.api.mvc.*
import javax.inject.* import javax.inject.*
@@ -28,28 +29,35 @@ class UserController @Inject()(
if (possibleUser.isDefined) { if (possibleUser.isDefined) {
Redirect(routes.MainMenuController.mainMenu()) Redirect(routes.MainMenuController.mainMenu())
} else { } else {
Ok(views.html.login.login()) Ok(views.html.main("Login")(views.html.login.login()))
} }
} else { } else {
Ok(views.html.login.login()) Ok(views.html.main("Login")(views.html.login.login()))
} }
} }
} }
def login_Post(): Action[AnyContent] = { def login_Post(): Action[AnyContent] = {
Action { implicit request => Action { implicit request =>
val postData = request.body.asFormUrlEncoded val jsonBody = request.body.asJson
if (postData.isDefined) { val username: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "username").asOpt[String]
}
val password: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "password").asOpt[String]
}
if (username.isDefined && password.isDefined) {
// Extract username and password from form data // Extract username and password from form data
val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") val possibleUser = userManager.authenticate(username.get, password.get)
val password = postData.get.get("password").flatMap(_.headOption).getOrElse("")
val possibleUser = userManager.authenticate(username, password)
if (possibleUser.isDefined) { if (possibleUser.isDefined) {
Redirect(routes.MainMenuController.mainMenu()).withCookies( Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(possibleUser).toString
)).withCookies(
Cookie("sessionId", sessionManager.createSession(possibleUser.get)) Cookie("sessionId", sessionManager.createSession(possibleUser.get))
) )
} else { } else {
println("Failed login attempt for user: " + username)
Unauthorized("Invalid username or password") Unauthorized("Invalid username or password")
} }
} else { } else {
@@ -0,0 +1,45 @@
package controllers
import auth.AuthAction
import logic.PodManager
import logic.user.SessionManager
import model.sessions.{UserSession, UserWebsocketActor}
import org.apache.pekko.actor.{ActorRef, ActorSystem, Props}
import org.apache.pekko.stream.Materializer
import play.api.*
import play.api.libs.streams.ActorFlow
import play.api.mvc.*
import javax.inject.*
@Singleton
class WebsocketController @Inject()(
cc: ControllerComponents,
val sessionManger: SessionManager,
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
def socket(): WebSocket = WebSocket.accept[String, String] { request =>
val session = request.cookies.get("sessionId")
if (session.isEmpty) throw new Exception("No session cookie found")
val userOpt = sessionManger.getUserBySession(session.get.value)
if (userOpt.isEmpty) throw new Exception("Invalid session")
val user = userOpt.get
val game = PodManager.identifyGameOfUser(user)
if (game.isEmpty) throw new Exception("User is not in a game")
val userSession = game.get.getUserSession(user.id)
ActorFlow.actorRef { out =>
println("Connect received")
KnockOutWebSocketActorFactory.create(out, userSession)
}
}
object KnockOutWebSocketActorFactory {
def create(out: ActorRef, userSession: UserSession): Props = {
Props(new UserWebsocketActor(out, userSession))
}
}
}
@@ -0,0 +1,9 @@
package events
import model.users.User
case class KickEvent(user: User) extends UserEvent(user) {
override def id: String = "KickEvent"
}
@@ -0,0 +1,9 @@
package events
import model.users.User
case class LeftEvent(user: User) extends UserEvent(user) {
override def id: String = "LeftEvent"
}
@@ -0,0 +1,9 @@
package events
import de.knockoutwhist.utils.events.SimpleEvent
case class LobbyUpdateEvent() extends SimpleEvent {
override def id: String = "LobbyUpdateEvent"
}
@@ -0,0 +1,12 @@
package events
import de.knockoutwhist.utils.events.SimpleEvent
import model.users.User
import java.util.UUID
abstract class UserEvent(user: User) extends SimpleEvent {
def userId: UUID = user.id
}
+34 -10
View File
@@ -11,20 +11,20 @@ import util.GameUtil
import javax.inject.Singleton import javax.inject.Singleton
import scala.collection.mutable import scala.collection.mutable
@Singleton object PodManager {
class PodManager {
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
val podIp: String = System.getenv("POD_IP") val podIp: String = System.getenv("POD_IP")
val podName: String = System.getenv("POD_NAME") val podName: String = System.getenv("POD_NAME")
private val sessions: mutable.Map[String, GameLobby] = mutable.Map() private val sessions: mutable.Map[String, GameLobby] = mutable.Map()
private val userSession: mutable.Map[User, String] = mutable.Map()
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
def createGame( def createGame(
host: User, host: User,
name: String, name: String,
maxPlayers: Int maxPlayers: Int
): GameLobby = { ): GameLobby = {
val gameLobby = GameLobby( val gameLobby = GameLobby(
logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), logic = BaseGameLogic(injector.getInstance(classOf[Configuration])),
@@ -35,15 +35,39 @@ class PodManager {
host = host host = host
) )
sessions += (gameLobby.id -> gameLobby) sessions += (gameLobby.id -> gameLobby)
userSession += (host -> gameLobby.id)
gameLobby gameLobby
} }
def getGame(gameId: String): Option[GameLobby] = { def getGame(gameId: String): Option[GameLobby] = {
sessions.get(gameId) sessions.get(gameId)
} }
private[logic] def removeGame(gameId: String): Unit = { def registerUserToGame(user: User, gameId: String): Boolean = {
sessions.remove(gameId) if (sessions.contains(gameId)) {
userSession += (user -> gameId)
true
} else {
false
}
} }
def unregisterUserFromGame(user: User): Unit = {
userSession.remove(user)
}
def identifyGameOfUser(user: User): Option[GameLobby] = {
userSession.get(user) match {
case Some(gameId) => sessions.get(gameId)
case None => None
}
}
private[logic] def removeGame(gameId: String): Unit = {
sessions.remove(gameId)
// Also remove all user sessions associated with this game
userSession.filterInPlace((_, v) => v != gameId)
}
} }
+162 -111
View File
@@ -10,35 +10,42 @@ import de.knockoutwhist.player.Playertype.HUMAN
import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory}
import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.rounds.{Match, Round, Trick}
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import events.{KickEvent, LeftEvent, LobbyUpdateEvent, UserEvent}
import exceptions.* import exceptions.*
import logic.PodManager
import model.sessions.{InteractionType, UserSession} import model.sessions.{InteractionType, UserSession}
import model.users.User import model.users.User
import play.api.libs.json.{JsObject, Json}
import java.util.UUID import java.util.{Timer, TimerTask, UUID}
import scala.collection.mutable import scala.collection.mutable
import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer
class GameLobby private( class GameLobby private(
val logic: GameLogic, val logic: GameLogic,
val id: String, val id: String,
val internalId: UUID, val internalId: UUID,
val name: String, val name: String,
val maxPlayers: Int val maxPlayers: Int
) extends EventListener { ) extends EventListener {
private val users: mutable.Map[UUID, UserSession] = mutable.Map()
logic.addListener(this) logic.addListener(this)
logic.createSession() logic.createSession()
private val users: mutable.Map[UUID, UserSession] = mutable.Map()
def addUser(user: User): UserSession = { def addUser(user: User): UserSession = {
if (users.size >= maxPlayers) throw new GameFullException("The game is full!") if (users.size >= maxPlayers) throw new GameFullException("The game is full!")
if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!") if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!")
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!") if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
val userSession = new UserSession( val userSession = new UserSession(
user = user, user = user,
host = false host = false,
gameLobby = this
) )
users += (user.id -> userSession) users += (user.id -> userSession)
PodManager.registerUserToGame(user, id)
logic.invoke(LobbyUpdateEvent())
userSession userSession
} }
@@ -46,13 +53,13 @@ class GameLobby private(
event match { event match {
case event: PlayerEvent => case event: PlayerEvent =>
users.get(event.playerId).foreach(session => session.updatePlayer(event)) users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: UserEvent =>
users.get(event.userId).foreach(session => session.updatePlayer(event))
case event: GameStateChangeEvent => case event: GameStateChangeEvent =>
if (event.oldState == MainMenu && event.newState == Lobby) { if (event.oldState == MainMenu && event.newState == Lobby) {
return return
} }
users.values.foreach(session => session.updatePlayer(event)) users.values.foreach(session => session.updatePlayer(event))
case event: SessionClosed =>
users.values.foreach(session => session.updatePlayer(event))
case event: SimpleEvent => case event: SimpleEvent =>
users.values.foreach(session => session.updatePlayer(event)) users.values.foreach(session => session.updatePlayer(event))
} }
@@ -60,6 +67,7 @@ class GameLobby private(
/** /**
* Start the game if the user is the host. * Start the game if the user is the host.
*
* @param user the user who wants to start the game. * @param user the user who wants to start the game.
*/ */
def startGame(user: User): Unit = { def startGame(user: User): Unit = {
@@ -86,20 +94,36 @@ class GameLobby private(
/** /**
* Remove the user from the game lobby. * Remove the user from the game lobby.
*
* @param user the user who wants to leave the game. * @param user the user who wants to leave the game.
* @param kicked whether the user was kicked or left voluntarily.
*/ */
def leaveGame(userId: UUID): Unit = { def leaveGame(userId: UUID, kicked: Boolean): Unit = {
val sessionOpt = users.get(userId) val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) { if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!") throw new NotInThisGameException("You are not in this game!")
} }
if (sessionOpt.get.host) {
logic.invoke(SessionClosed())
users.clear()
PodManager.removeGame(id)
return
}
if (kicked) {
logic.invoke(KickEvent(sessionOpt.get.user))
} else {
logic.invoke(LeftEvent(sessionOpt.get.user))
}
users.remove(userId) users.remove(userId)
PodManager.unregisterUserFromGame(sessionOpt.get.user)
logic.invoke(LobbyUpdateEvent())
} }
/** /**
* Play a card from the player's hand. * Play a card from the player's hand.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand. * @param cardIndex the index of the card in the player's hand.
*/ */
def playCard(userSession: UserSession, cardIndex: Int): Unit = { def playCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.Card) val player = getPlayerInteractable(userSession, InteractionType.Card)
@@ -115,93 +139,6 @@ class GameLobby private(
logic.playerInputLogic.receivedCard(card) logic.playerInputLogic.receivedCard(card)
} }
/**
* Play a card from the player's hand while in dog life or skip the round.
* @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round.
*/
def playDogCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.DogCard)
if (!player.isInDogLife) {
throw new CantPlayCardException("You are not in dog life!")
}
if (cardIndex == -1) {
if (!MatchUtil.dogNeedsToPlay(getMatch, getRound)) {
throw new CantPlayCardException("You can't skip this round!")
}
logic.playerInputLogic.receivedDog(None)
}
val hand = getHand(player)
val card = hand.cards(cardIndex)
userSession.resetCanInteract()
logic.playerInputLogic.receivedDog(Some(card))
}
/**
* Select the trump suit for the round.
* @param userSession the user session of the player.
* @param trumpIndex the index of the trump suit.
*/
def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit)
val trumpSuits = Suit.values.toList
val selectedTrump = trumpSuits(trumpIndex)
userSession.resetCanInteract()
logic.playerInputLogic.receivedTrumpSuit(selectedTrump)
}
/**
*
* @param userSession
* @param tieNumber
*/
def selectTie(userSession: UserSession, tieNumber: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TieChoice)
userSession.resetCanInteract()
logic.playerTieLogic.receivedTieBreakerCard(tieNumber)
}
//-------------------
def getUserSession(userId: UUID): UserSession = {
val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
sessionOpt.get
}
def getPlayerByUser(user: User): AbstractPlayer = {
getPlayerBySession(getUserSession(user.id))
}
def getPlayers: mutable.Map[UUID, UserSession] = {
users.clone()
}
def getLogic: GameLogic = {
logic
}
private def getPlayerBySession(userSession: UserSession): AbstractPlayer = {
val playerOption = getMatch.totalplayers.find(_.id == userSession.id)
if (playerOption.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
playerOption.get
}
private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = {
if (!userSession.lock.isHeldByCurrentThread) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
private def getHand(player: AbstractPlayer): Hand = { private def getHand(player: AbstractPlayer): Hand = {
val handOption = player.currentHand() val handOption = player.currentHand()
if (handOption.isEmpty) { if (handOption.isEmpty) {
@@ -209,14 +146,6 @@ class GameLobby private(
} }
handOption.get handOption.get
} }
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
}
private def getRound: Round = { private def getRound: Round = {
val roundOpt = logic.getCurrentRound val roundOpt = logic.getCurrentRound
@@ -233,7 +162,128 @@ class GameLobby private(
} }
trickOpt.get trickOpt.get
} }
/**
* Play a card from the player's hand while in dog life or skip the round.
*
* @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round.
*/
def playDogCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.DogCard)
if (!player.isInDogLife) {
throw new CantPlayCardException("You are not in dog life!")
}
if (cardIndex == -1) {
if (MatchUtil.dogNeedsToPlay(getMatch, getRound)) {
throw new CantPlayCardException("You can't skip this round!")
}
logic.playerInputLogic.receivedDog(None)
return
}
val hand = getHand(player)
val card = hand.cards(cardIndex)
userSession.resetCanInteract()
logic.playerInputLogic.receivedDog(Some(card))
}
/**
* Select the trump suit for the round.
*
* @param userSession the user session of the player.
* @param trumpIndex the index of the trump suit.
*/
def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit)
val trumpSuits = Suit.values.toList
val selectedTrump = trumpSuits(trumpIndex)
userSession.resetCanInteract()
logic.playerInputLogic.receivedTrumpSuit(selectedTrump)
}
//-------------------
private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = {
if (!userSession.lock.isHeldByCurrentThread) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
/**
*
* @param userSession
* @param tieNumber
*/
def selectTie(userSession: UserSession, tieNumber: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TieChoice)
userSession.resetCanInteract()
logic.playerTieLogic.receivedTieBreakerCard(tieNumber)
}
def returnToLobby(userSession: UserSession): Unit = {
if (!users.contains(userSession.id)) {
throw new NotInThisGameException("You are not in this game!")
}
val session = users(userSession.id)
if (session != userSession) {
throw new IllegalArgumentException("User session does not match!")
}
if (!session.host)
throw new NotHostException("Only the host can return to the lobby!")
logic.createSession()
}
def getPlayerByUser(user: User): AbstractPlayer = {
getPlayerBySession(getUserSession(user.id))
}
def getUserSession(userId: UUID): UserSession = {
val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
sessionOpt.get
}
private def getPlayerBySession(userSession: UserSession): AbstractPlayer = {
val playerOption = getMatch.totalplayers.find(_.id == userSession.id)
if (playerOption.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
playerOption.get
}
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
}
def getPlayers: mutable.Map[UUID, UserSession] = {
users.clone()
}
def getLogic: GameLogic = {
logic
}
def getUsers: Set[User] = {
users.values.map(d => d.user).toSet
}
private def transmitToAll(event: JsObject): Unit = {
users.values.foreach(session => {
session.websocketActor.foreach(act => act.transmitJsonToClient(event))
})
}
} }
object GameLobby { object GameLobby {
@@ -254,7 +304,8 @@ object GameLobby {
) )
lobby.users += (host.id -> new UserSession( lobby.users += (host.id -> new UserSession(
user = host, user = host,
host = true host = true,
gameLobby = lobby
)) ))
lobby lobby
} }
@@ -6,9 +6,11 @@ import model.users.User
@ImplementedBy(classOf[BaseSessionManager]) @ImplementedBy(classOf[BaseSessionManager])
trait SessionManager { trait SessionManager {
def createSession(user: User): String def createSession(user: User): String
def getUserBySession(sessionId: String): Option[User] def getUserBySession(sessionId: String): Option[User]
def invalidateSession(sessionId: String): Unit def invalidateSession(sessionId: String): Unit
} }
@@ -8,9 +8,13 @@ import model.users.User
trait UserManager { trait UserManager {
def addUser(name: String, password: String): Boolean def addUser(name: String, password: String): Boolean
def authenticate(name: String, password: String): Option[User] def authenticate(name: String, password: String): Option[User]
def userExists(name: String): Option[User] def userExists(name: String): Option[User]
def userExistsById(id: Long): Option[User] def userExistsById(id: Long): Option[User]
def removeUser(name: String): Boolean def removeUser(name: String): Boolean
} }
@@ -9,7 +9,7 @@ import javax.inject.{Inject, Singleton}
@Singleton @Singleton
class StubUserManager @Inject()(val config: Config) extends UserManager { class StubUserManager @Inject()(val config: Config) extends UserManager {
private val user: Map[String, User] = Map( private val user: Map[String, User] = Map(
"Janis" -> User( "Janis" -> User(
internalId = 1L, internalId = 1L,
@@ -53,5 +53,5 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
override def removeUser(name: String): Boolean = { override def removeUser(name: String): Boolean = {
throw new NotImplementedError("StubUserManager.removeUser is not implemented") throw new NotImplementedError("StubUserManager.removeUser is not implemented")
} }
} }
@@ -1,7 +1,7 @@
package model.sessions package model.sessions
enum InteractionType { enum InteractionType {
case TrumpSuit case TrumpSuit
case Card case Card
case DogCard case DogCard
@@ -5,9 +5,11 @@ import de.knockoutwhist.utils.events.SimpleEvent
import java.util.UUID import java.util.UUID
trait PlayerSession { trait PlayerSession {
def id: UUID def id: UUID
def name: String def name: String
def updatePlayer(event: SimpleEvent): Unit def updatePlayer(event: SimpleEvent): Unit
} }
@@ -6,9 +6,9 @@ import de.knockoutwhist.utils.events.SimpleEvent
import java.util.UUID import java.util.UUID
case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession { case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession {
def name: String = player.name def name: String = player.name
override def updatePlayer(event: SimpleEvent): Unit = { override def updatePlayer(event: SimpleEvent): Unit = {
} }
} }
@@ -2,14 +2,18 @@ package model.sessions
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent} import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User import model.users.User
import play.api.libs.json.{JsObject, JsValue}
import java.util.UUID import java.util.UUID
import java.util.concurrent.locks.{Lock, ReentrantLock} import java.util.concurrent.locks.ReentrantLock
import scala.util.Try
class UserSession(user: User, val host: Boolean) extends PlayerSession { class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) extends PlayerSession {
var canInteract: Option[InteractionType] = None
val lock: ReentrantLock = ReentrantLock() val lock: ReentrantLock = ReentrantLock()
var canInteract: Option[InteractionType] = None
var websocketActor: Option[UserWebsocketActor] = None
override def updatePlayer(event: SimpleEvent): Unit = { override def updatePlayer(event: SimpleEvent): Unit = {
event match { event match {
@@ -22,14 +26,62 @@ class UserSession(user: User, val host: Boolean) extends PlayerSession {
else canInteract = Some(InteractionType.Card) else canInteract = Some(InteractionType.Card)
case _ => case _ =>
} }
websocketActor.foreach(_.transmitEventToClient(event))
} }
override def id: UUID = user.id override def id: UUID = user.id
override def name: String = user.name override def name: String = user.name
def resetCanInteract(): Unit = { def resetCanInteract(): Unit = {
canInteract = None canInteract = None
} }
def handleWebResponse(eventType: String, data: JsObject): Unit = {
lock.lock()
val result = Try {
eventType match {
case "ping" =>
// No action needed for Ping
()
case "StartGame" =>
gameLobby.startGame(user)
case "PlayCard" =>
val maybeCardIndex: Option[String] = (data \ "cardindex").asOpt[String]
maybeCardIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.playCard(session, index.toInt)
case None =>
println("Card Index not found or is not a number.")
}
case "PickTrumpsuit" =>
val maybeSuitIndex: Option[Int] = (data \ "suitIndex").asOpt[Int]
maybeSuitIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.selectTrump(session, index)
case None =>
println("Card Index not found or is not a number.")
}
case "KickPlayer" =>
val maybePlayerId: Option[String] = (data \ "playerId").asOpt[String]
maybePlayerId match {
case Some(id) =>
val playerUUID = UUID.fromString(id)
gameLobby.leaveGame(playerUUID, true)
case None =>
println("Player ID not found or is not a valid UUID.")
}
case "ReturnToLobby" =>
gameLobby.returnToLobby(this)
}
}
lock.unlock()
if (result.isFailure) {
val throwable = result.failed.get
throw throwable
}
}
} }
@@ -0,0 +1,103 @@
package model.sessions
import de.knockoutwhist.utils.events.SimpleEvent
import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper
import scala.util.{Failure, Success, Try}
class UserWebsocketActor(
out: ActorRef,
session: UserSession
) extends Actor {
{
session.lock.lock()
if (session.websocketActor.isDefined) {
val otherWebsocket = session.websocketActor.get
otherWebsocket.transmitTextToClient("Error: Multiple websocket connections detected. Closing your connection.")
context.stop(otherWebsocket.self)
transmitTextToClient("Previous websocket connection closed. You are now connected.")
}
session.websocketActor = Some(this)
session.lock.unlock()
}
override def receive: Receive = {
case msg: String =>
val jsonObject = Try {
Json.parse(msg)
}
Try {
jsonObject match {
case Success(value) =>
handle(value)
case Failure(exception) =>
transmitTextToClient(s"Error parsing JSON: ${exception.getMessage}")
}
}.failed.foreach(
ex => transmitTextToClient(s"Error handling message: ${ex.getMessage}")
)
case other =>
}
private def transmitTextToClient(text: String): Unit = {
out ! text
}
private def handle(json: JsValue): Unit = {
val idOpt = (json \ "id").asOpt[String]
if (idOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"status" -> "error",
"error" -> "Missing 'id' field"
))
return
}
val id = idOpt.get
val eventOpt = (json \ "event").asOpt[String]
if (eventOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> null,
"status" -> "error",
"error" -> "Missing 'event' field"
))
return
}
val statusOpt = (json \ "status").asOpt[String]
if (statusOpt.isDefined) {
return
}
val event = eventOpt.get
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
val result = Try {
session.handleWebResponse(event, data)
}
if (result.isSuccess) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "success"
))
} else {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "error",
"error" -> result.failed.get.getMessage
))
}
}
def transmitJsonToClient(jsonObj: JsValue): Unit = {
transmitTextToClient(jsonObj.toString())
}
def transmitEventToClient(event: SimpleEvent): Unit = {
transmitJsonToClient(WebsocketEventMapper.toJson(event, session))
}
}
+6 -6
View File
@@ -1,12 +1,13 @@
package model.users package model.users
import java.util.UUID import java.util.UUID
case class User( case class User(
internalId: Long, internalId: Long,
id: UUID, id: UUID,
name: String, name: String,
passwordHash: String passwordHash: String
) { ) {
def withName(newName: String): User = { def withName(newName: String): User = {
@@ -16,5 +17,4 @@ case class User(
private def withPasswordHash(newPasswordHash: String): User = { private def withPasswordHash(newPasswordHash: String): User = {
this.copy(passwordHash = newPasswordHash) this.copy(passwordHash = newPasswordHash)
} }
}
}
@@ -12,10 +12,28 @@ import javax.inject.*
@Singleton @Singleton
class JwtKeyProvider @Inject()(config: Configuration) { class JwtKeyProvider @Inject()(config: Configuration) {
private def cleanPem(pem: String): String = val publicKey: RSAPublicKey = {
pem.replaceAll("-----BEGIN (.*)-----", "") val pemOpt = config.getOptional[String]("auth.publicKeyPem")
.replaceAll("-----END (.*)-----", "") val fileOpt = config.getOptional[String]("auth.publicKeyFile")
.replaceAll("\\s", "")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPublicKeyFromPem(pem)
case None => throw new RuntimeException("No RSA public key configured.")
}
}
val privateKey: RSAPrivateKey = {
val pemOpt = config.getOptional[String]("auth.privateKeyPem")
val fileOpt = config.getOptional[String]("auth.privateKeyFile")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPrivateKeyFromPem(pem)
case None => throw new RuntimeException("No RSA private key configured.")
}
}
private def loadPublicKeyFromPem(pem: String): RSAPublicKey = { private def loadPublicKeyFromPem(pem: String): RSAPublicKey = {
val decoded = Base64.getDecoder.decode(cleanPem(pem)) val decoded = Base64.getDecoder.decode(cleanPem(pem))
@@ -29,28 +47,9 @@ class JwtKeyProvider @Inject()(config: Configuration) {
KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey]
} }
val publicKey: RSAPublicKey = { private def cleanPem(pem: String): String =
val pemOpt = config.getOptional[String]("auth.publicKeyPem") pem.replaceAll("-----BEGIN (.*)-----", "")
val fileOpt = config.getOptional[String]("auth.publicKeyFile") .replaceAll("-----END (.*)-----", "")
.replaceAll("\\s", "")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPublicKeyFromPem(pem)
case None => throw new RuntimeException("No RSA public key configured.")
}
}
val privateKey: RSAPrivateKey = {
val pemOpt = config.getOptional[String]("auth.privateKeyPem")
val fileOpt = config.getOptional[String]("auth.privateKeyFile")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPrivateKeyFromPem(pem)
case None => throw new RuntimeException("No RSA private key configured.")
}
}
} }
+14
View File
@@ -1,5 +1,8 @@
package util package util
import de.knockoutwhist.control.GameState
import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, MainMenu, SelectTrump, TieBreak}
import scala.util.Random import scala.util.Random
object GameUtil { object GameUtil {
@@ -26,4 +29,15 @@ object GameUtil {
code.toString() code.toString()
} }
def stateToTitle(gameState: GameState): String = {
gameState match {
case Lobby => "Lobby"
case MainMenu => "Main Menu"
case InGame => "In Game"
case SelectTrump => "Select Trump"
case TieBreak => "Tie Break"
case FinishedMatch => "Finished Match"
}
}
} }
+26 -2
View File
@@ -1,13 +1,18 @@
package util package util
import de.knockoutwhist.cards.Card
import de.knockoutwhist.cards.CardValue.* import de.knockoutwhist.cards.CardValue.*
import de.knockoutwhist.cards.Suit.{Clubs, Diamonds, Hearts, Spades} import de.knockoutwhist.cards.Suit.{Clubs, Diamonds, Hearts, Spades}
import de.knockoutwhist.cards.{Card, Hand}
import play.api.libs.json.{JsArray, Json}
import play.twirl.api.Html import play.twirl.api.Html
import scalafx.scene.image.Image import scalafx.scene.image.Image
object WebUIUtils { object WebUIUtils {
def cardtoImage(card: Card): Html = { def cardtoImage(card: Card): Html = {
views.html.render.card.apply(f"images/cards/${cardtoString(card)}.png")(card.toString)
}
def cardtoString(card: Card) = {
val s = card.suit match { val s = card.suit match {
case Spades => "S" case Spades => "S"
case Hearts => "H" case Hearts => "H"
@@ -29,6 +34,25 @@ object WebUIUtils {
case Three => "3" case Three => "3"
case Two => "2" case Two => "2"
} }
views.html.render.card.apply(f"images/cards/$cv$s.png")(card.toString) f"$cv$s"
} }
/**
* Map a Hand to a JsArray of cards
* Per card it has the string and the index in the hand
* @param hand
* @return
*/
def handToJson(hand: Hand): JsArray = {
val cards = hand.cards
JsArray(
cards.zipWithIndex.map { case (card, index) =>
Json.obj(
"idx" -> index,
"card" -> cardtoString(card)
)
}
)
}
} }
@@ -0,0 +1,55 @@
package util
import de.knockoutwhist.utils.events.SimpleEvent
import model.sessions.UserSession
import play.api.libs.json.{JsValue, Json}
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule
import util.mapper.*
object WebsocketEventMapper {
private val scalaModule = ScalaModule.builder()
.addAllBuiltinModules()
.supportScala3Classes(true)
.build()
private val mapper = JsonMapper.builder().addModule(scalaModule).build()
private var customMappers: Map[String,SimpleEventMapper[SimpleEvent]] = Map()
private def registerCustomMapper[T <: SimpleEvent](mapper: SimpleEventMapper[T]): Unit = {
customMappers = customMappers + (mapper.id -> mapper.asInstanceOf[SimpleEventMapper[SimpleEvent]])
}
// Register all custom mappers here
registerCustomMapper(ReceivedHandEventMapper)
registerCustomMapper(GameStateEventMapper)
registerCustomMapper(CardPlayedEventMapper)
registerCustomMapper(NewRoundEventMapper)
registerCustomMapper(NewTrickEventMapper)
registerCustomMapper(TrickEndEventMapper)
registerCustomMapper(RequestCardEventMapper)
registerCustomMapper(LobbyUpdateEventMapper)
registerCustomMapper(LeftEventMapper)
registerCustomMapper(KickEventMapper)
registerCustomMapper(SessionClosedMapper)
registerCustomMapper(TurnEventMapper)
def toJson(obj: SimpleEvent, session: UserSession): JsValue = {
val data: Option[JsValue] = if (customMappers.contains(obj.id)) {
Some(customMappers(obj.id).toJson(obj, session))
}else {
None
}
if (data.isEmpty) {
return Json.obj()
}
Json.obj(
"id" -> ("request-" + java.util.UUID.randomUUID().toString),
"event" -> obj.id,
"data" -> data
)
}
}
@@ -0,0 +1,20 @@
package util.mapper
import de.knockoutwhist.events.global.CardPlayedEvent
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
import util.WebUIUtils
object CardPlayedEventMapper extends SimpleEventMapper[CardPlayedEvent]{
override def id: String = "CardPlayedEvent"
override def toJson(event: CardPlayedEvent, session: UserSession): JsObject = {
Json.obj(
"firstCard" -> (if (event.trick.firstCard.isDefined) WebUIUtils.cardtoString(event.trick.firstCard.get) else "BLANK"),
"playedCards" -> JsArray(event.trick.cards.map { case (card, player) =>
Json.obj("cardId" -> WebUIUtils.cardtoString(card), "player" -> player.name)
}.toList)
)
}
}
@@ -0,0 +1,19 @@
package util.mapper
import controllers.IngameController
import de.knockoutwhist.events.global.GameStateChangeEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
import util.GameUtil
object GameStateEventMapper extends SimpleEventMapper[GameStateChangeEvent] {
override def id: String = "GameStateChangeEvent"
override def toJson(event: GameStateChangeEvent, session: UserSession): JsObject = {
Json.obj(
"title" -> ("Knockout Whist - " + GameUtil.stateToTitle(event.newState)),
"content" -> IngameController.returnInnerHTML(session.gameLobby, event.newState, session.user).toString
)
}
}
@@ -0,0 +1,19 @@
package util.mapper
import controllers.routes
import events.KickEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object KickEventMapper extends SimpleEventMapper[KickEvent] {
override def id: String = "KickEvent"
override def toJson(event: KickEvent, session: UserSession): JsObject = {
Json.obj(
"url" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(session.user)).toString,
)
}
}
@@ -0,0 +1,19 @@
package util.mapper
import controllers.routes
import events.{KickEvent, LeftEvent}
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object LeftEventMapper extends SimpleEventMapper[LeftEvent] {
override def id: String = "LeftEvent"
override def toJson(event: LeftEvent, session: UserSession): JsObject = {
Json.obj(
"url" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(session.user)).toString
)
}
}
@@ -0,0 +1,25 @@
package util.mapper
import events.LobbyUpdateEvent
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
object LobbyUpdateEventMapper extends SimpleEventMapper[LobbyUpdateEvent] {
override def id: String = "LobbyUpdateEvent"
override def toJson(event: LobbyUpdateEvent, session: UserSession): JsObject = {
Json.obj(
"host" -> session.host,
"maxPlayers" -> session.gameLobby.maxPlayers,
"players" -> JsArray(session.gameLobby.getPlayers.values.map(player => {
Json.obj(
"id" -> player.id,
"name" -> player.name,
"self" -> (player.id == session.user.id)
)
}).toList)
)
}
}
@@ -0,0 +1,18 @@
package util.mapper
import de.knockoutwhist.events.global.NewRoundEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object NewRoundEventMapper extends SimpleEventMapper[NewRoundEvent]{
override def id: String = "NewRoundEvent"
override def toJson(event: NewRoundEvent, session: UserSession): JsObject = {
val gameLobby = session.gameLobby
Json.obj(
"trumpsuit" -> gameLobby.getLogic.getCurrentRound.get.trumpSuit.toString,
"players" -> gameLobby.getLogic.getCurrentMatch.get.playersIn.map(player => player.toString)
)
}
}
@@ -0,0 +1,14 @@
package util.mapper
import de.knockoutwhist.events.global.NewTrickEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object NewTrickEventMapper extends SimpleEventMapper[NewTrickEvent]{
override def id: String = "NewTrickEvent"
override def toJson(event: NewTrickEvent, session: UserSession): JsObject = {
Json.obj()
}
}
@@ -0,0 +1,17 @@
package util.mapper
import de.knockoutwhist.events.player.ReceivedHandEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
import util.WebUIUtils
object ReceivedHandEventMapper extends SimpleEventMapper[ReceivedHandEvent] {
override def id: String = "ReceivedHandEvent"
override def toJson(event: ReceivedHandEvent, session: UserSession): JsObject = {
Json.obj(
"dog" -> event.player.isInDogLife,
"hand" -> event.player.currentHand().map(hand => WebUIUtils.handToJson(hand))
)
}
}
@@ -0,0 +1,16 @@
package util.mapper
import de.knockoutwhist.events.player.RequestCardEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object RequestCardEventMapper extends SimpleEventMapper[RequestCardEvent]{
override def id: String = "RequestCardEvent"
override def toJson(event: RequestCardEvent, session: UserSession): JsObject = {
Json.obj(
"player" -> event.player.name
)
}
}
@@ -0,0 +1,20 @@
package util.mapper
import controllers.routes
import de.knockoutwhist.events.global.RoundEndEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object RoundEndEventMapper extends SimpleEventMapper[RoundEndEvent] {
override def id: String = "RoundEndEvent"
override def toJson(event: RoundEndEvent, session: UserSession): JsObject = {
Json.obj(
"player" -> event.winner.name,
"tricks" -> event.amountOfTricks
)
}
}
@@ -0,0 +1,19 @@
package util.mapper
import controllers.routes
import de.knockoutwhist.events.global.SessionClosed
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object SessionClosedMapper extends SimpleEventMapper[SessionClosed] {
override def id: String = "SessionClosed"
override def toJson(event: SessionClosed, session: UserSession): JsObject = {
Json.obj(
"url" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(session.user)).toString,
)
}
}
@@ -0,0 +1,13 @@
package util.mapper
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.JsObject
trait SimpleEventMapper[T <: SimpleEvent] {
def id: String
def toJson(event: T, session: UserSession): JsObject
}
@@ -0,0 +1,20 @@
package util.mapper
import de.knockoutwhist.events.global.TrickEndEvent
import de.knockoutwhist.rounds.Trick
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object TrickEndEventMapper extends SimpleEventMapper[TrickEndEvent]{
override def id: String = "TrickEndEvent"
override def toJson(event: TrickEndEvent, session: UserSession): JsObject = {
val gameLobby = session.gameLobby
Json.obj(
"playerwon" -> event.winner.name,
"playersin" -> gameLobby.getLogic.getCurrentMatch.get.playersIn.map(player => player.name),
"tricklist" -> gameLobby.getLogic.getCurrentRound.get.tricklist.map(trick => trick.winner.map(player => player.name).getOrElse("Trick in Progress"))
)
}
}
@@ -0,0 +1,35 @@
package util.mapper
import de.knockoutwhist.events.global.TurnEvent
import de.knockoutwhist.player.AbstractPlayer
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
object TurnEventMapper extends SimpleEventMapper[TurnEvent] {
override def id: String = "TurnEvent"
override def toJson(event: TurnEvent, session: UserSession): JsObject = {
val nextPlayers = if (session.gameLobby.logic.getPlayerQueue.isEmpty) {
Json.arr()
} else {
val queue = session.gameLobby.logic.getPlayerQueue.get
JsArray(
queue.duplicate().map(player => mapPlayer(player)).toList
)
}
Json.obj(
"currentPlayer" -> mapPlayer(event.player),
"nextPlayers" -> nextPlayers
)
}
private def mapPlayer(player: AbstractPlayer): JsObject = {
Json.obj(
"name" -> player.name,
"dog" -> player.isInDogLife
)
}
}
@@ -0,0 +1,27 @@
@(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
<main class="lobby-background vh-100" id="lobbybackground">
<div class="container d-flex flex-column" style="height: calc(100vh - 1rem);">
<div class="row">
<div class="col">
<div class="p-3 text-center fs-4">Winner: @gamelobby.getLogic.getWinner</div>
</div>
</div>
<div class="row justify-content-center align-items-center flex-grow-1">
@if((gamelobby.getUserSession(user.get.id).host)) {
<div class="col-12 text-center mb-5">
<div class="btn btn-success" onclick="handleReturnToLobby()">Return to lobby</div>
</div>
} else {
<div class="col-12 text-center mt-3">
<div class="spinner-border mt-1" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
</div>
</div>
</main>
<script>
connectWebSocket()
</script>
@@ -1,70 +1,135 @@
@import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.TrickUtil @import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.TrickUtil
@import de.knockoutwhist.utils.Implicits.*
@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby) @(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
@main("Ingame") { <div class="lobby-background vh-100">
<div class="py-5 ms-4 me-4"> <main class="game-field-background vh-100 ingame-side-shadow">
<div class="py-5 container-xxl">
<div class="row ms-4 me-4"> <div class="row ms-4 me-4">
<div class="col-4 mt-5 text-start"> <div class="col-4 mt-5 text-start">
<h4 class="fw-semibold mb-1">Current Player</h4> <h4 class="fw-semibold mb-1">Current Player</h4>
<p class="fs-5 text-primary">@gamelobby.getLogic.getCurrentPlayer.get.name</p> @if(gamelobby.getLogic.getCurrentPlayer.isDefined) {
@if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) { <p class="fs-5 text-primary" id="current-player-name">@gamelobby.getLogic.getCurrentPlayer.get.name</p>
<h4 class="fw-semibold mb-1">Next Player</h4> }else {
@for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) { <p class="fs-5 text-primary" id="current-player-name">---</p>
<p class="fs-5 text-primary">@nextplayer</p>
} }
} @if(gamelobby.getLogic.getPlayerQueue.isDefined && gamelobby.getLogic.getCurrentMatch && !TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) {
</div> <h4 class="fw-semibold mb-1" id="next-players-text">Next Players</h4>
<div id="next-players-container">
<div class="d-flex col-md-4 text-center justify-content-center g-3 mb-5"> @for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) {
@for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) { <p class="fs-5 text-primary">@nextplayer @if(nextplayer.isInDogLife) {
<div class="col-auto"> 🐶
<div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem; backdrop-filter: blur(4px);"> }</p>
<div class="p-2"> }
@util.WebUIUtils.cardtoImage(cardplayed) width="100%"/>
</div>
<div class="card-body p-2 bg-transparent">
<small class="fw-semibold text-secondary">@player</small>
</div>
</div> </div>
} else {
<h4 class="fw-semibold mb-1" style="display: none;" id="next-players-text">Next Players</h4>
<div id="next-players-container">
</div>
}
</div>
<div class="col-4 text-center">
<div class="score-table mt-5" id="score-table-body">
<h4 class="fw-bold mb-3 text-black">Tricks Won</h4>
<div class="d-flex justify-content-between score-header pb-1">
<div style="width: 50%">PLAYER</div>
<div style="width: 50%">TRICKS</div>
</div>
@if(gamelobby.getLogic.getPlayerQueue.isDefined) {
@for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p =>
-(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size)
}) {
<div class="d-flex justify-content-between score-row pt-1">
<div style="width: 50%" class="text-truncate">@player.name</div>
<div style="width: 50%">
@(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(player) }.size)
</div>
</div>
}
}else{
<div class="d-flex justify-content-between score-row pt-1">
</div>
}
</div> </div>
} <div class="d-flex justify-content-center g-3 mb-5" id="trick-cards-container">
@if(gamelobby.getLogic.getCurrentTrick.isEmpty || gamelobby.getLogic.getCurrentTrick.get.cards.isEmpty) {
<div class="col-auto">
</div>
} else {
@for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) {
<div class="col-auto">
<div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem;
backdrop-filter: blur(4px);">
<div class="p-2">
@util.WebUIUtils.cardtoImage(cardplayed) width="100%"/>
</div>
<div class="card-body p-2 bg-transparent">
<small class="fw-semibold text-secondary">@player</small>
</div>
</div>
</div>
}
}
</div>
</div>
<div class="col-4 mt-5 text-end">
<h4 class="fw-semibold mb-1">Trumpsuit</h4>
@if(gamelobby.getLogic.getCurrentRound.isEmpty) {
<p class="fs-5 text-primary" id="trumpsuit">No Trumpsuit</p>
}else {
<p class="fs-5 text-primary" id="trumpsuit">@gamelobby.getLogic.getCurrentRound.get.trumpSuit</p>
}
<h5 class="fw-semibold mt-4 mb-1">First Card</h5>
<div class="d-inline-block border rounded shadow-sm p-1 bg-light" id="first-card-container">
@if(gamelobby.getLogic.getCurrentTrick.isDefined && gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) {
@util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get)
width="80px"/>
} else {
@views.html.render.card.apply("images/cards/1B.png")("Blank Card") width="80px"/>
}
</div>
</div>
</div> </div>
<div class="col-md-4 text-end"> <div class="row justify-content-center g-2 mt-4 bottom-div" style="backdrop-filter: blur(4px);
<h4 class="fw-semibold mb-1">Trumpsuit</h4> margin-left: 0;
<p class="fs-5 text-primary">@gamelobby.getLogic.getCurrentRound.get.trumpSuit</p> margin-right: 0;">
<div class="row justify-content-center ingame-cards-slide @{
!gamelobby.logic.getCurrentPlayer.contains(player) ? "inactive" |: ""
}" id="card-slide">
@if(player.currentHand().isEmpty || player.currentHand().get.cards.isEmpty) {
<h5 class="fw-semibold mt-4 mb-1">First Card</h5> } else {
<div class="d-inline-block border rounded shadow-sm p-1 bg-light"> @for(i <- player.currentHand().get.cards.indices) {
@if(gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) { <div class="col-auto handcard" style="border-radius: 6px">
@util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get) width="80px"/> <div class="btn btn-outline-light p-0 border-0 shadow-none" data-card-id="@i" style="border-radius: 6px" onclick="handlePlayCard(this, '@player.isInDogLife')">
} else { @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
@views.html.render.card.apply("images/cards/1B.png")("Blank Card") width="80px"/> </div>
} @if(player.isInDogLife) {
<div class="mt-2">
<button class="btn btn-danger" onclick="handleSkipDogLife(this, '@gamelobby.id')">
Skip Dog Life</button>
</div>
}
</div>
}
}
</div> </div>
</div> </div>
</div> </div>
</main>
</div>
<div class="row"> <script>
connectWebSocket()
</div> canPlayCard = @gamelobby.logic.getCurrentPlayer.contains(player);
</script>
<div class="row justify-content-center g-2 mt-4 bottom-div" style="backdrop-filter: blur(4px);">
<div class="row justify-content-center" id="card-slide">
@for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto handcard" style="border-radius: 6px">
<form action="@(routes.IngameController.playCard(gamelobby.id))" method="post" class="m-0 p-0" style="border-radius: 6px">
<input type="hidden" name="cardId" value="@i" />
<button type="submit" class="btn btn-outline-light p-0 border-0 shadow-none" style="border-radius: 6px">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</button>
</form>
</div>
}
</div>
</div>
</div>
}
@@ -1,27 +1,68 @@
@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
@main("Selecting Trumpsuit...") {
<div id="selecttrumpsuit" class="game-field game-field-background"> <div id="selecttrumpsuit" class="game-field game-field-background">
@if(player.equals(logic.getCurrentMatch.get.roundlist.last.winner.get)) { <div class="ingame-stage blur-sides">
<h1>Knockout Whist</h1> <div class="container py-4">
<p>You (@player.toString) have won the last round! Select a trumpsuit for the next round!</p> <div class="row justify-content-center">
<p>Available trumpsuits are displayed below:</p> <div class="col-12">
<div id="playercards"> <div class="card shadow-sm">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades)) <div class="card-header text-center">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs)) <h3 class="mb-0">Select Trump Suit</h3>
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts)) </div>
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds)) <div class="card-body">
</div> @if(gamelobby.logic.getCurrentMatch.isDefined) {
<p>Your cards</p> @if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) {
<div class="alert alert-info" role="alert" aria-live="polite">
You (@player.toString) won the last round. Choose the trump suit for the next round.
</div>
<div id="playercards"> <div class="row justify-content-center col-auto mb-5">
@for(card <- player.currentHand().get.cards) { <div class="col-auto handcard">
@util.WebUIUtils.cardtoImage(card) <div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="0" style="border-radius: 6px" onclick="handleTrumpSelection(this)">
} @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades))
width="120px" style="border-radius: 6px"/>
</div>
</div>
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="1" style="border-radius: 6px" onclick="handleTrumpSelection(this)">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts))
width="120px" style="border-radius: 6px"/>
</div>
</div>
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="2" style="border-radius: 6px" onclick="handleTrumpSelection(this)">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds))
width="120px" style="border-radius: 6px"/>
</div>
</div>
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="3" style="border-radius: 6px" onclick="handleTrumpSelection(this)">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs))
width="120px" style="border-radius: 6px"/>
</div>
</div>
</div>
<div class="row justify-content-center ingame-cards-slide" id="card-slide">
@for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto" style="border-radius: 6px">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</div>
}
</div>
} else {
<div class="alert alert-warning" role="alert" aria-live="polite">
@gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get.name
is choosing a trumpsuit. The new round will start once a suit is picked.
</div>
}
}
</div>
</div>
</div>
</div>
</div> </div>
} else { </div>
<h1>Knockout Whist</h1>
<p>@player.toString is choosing a trumpsuit. Starting new round when @player.toString picked a trumpsuit...</p>
}
</div> </div>
} <script>
connectWebSocket()
</script>
+111 -24
View File
@@ -1,27 +1,114 @@
@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
@main("Tie") {
<div id="tie" class="game-field game-field-background"> <div id="tie" class="game-field game-field-background">
<h1>Knockout Whist</h1> <div class="ingame-stage blur-sides">
<p>The last Round was tied between <div class="container py-4">
@for(players <- logic.playerTieLogic.getTiedPlayers) { <div class="row justify-content-center">
@players <div class="col-12 col-md-10 col-lg-8">
} <div class="card shadow-sm">
</p> <div class="card-header text-center">
@if(player.equals(logic.playerTieLogic.currentTiePlayer())) { <h3 class="mb-0">Tie Break</h3>
<p>Pick a card between 1 and @logic.playerTieLogic.highestAllowedNumber()! The resulting card will be your card for the cut.</p> </div>
} else { <div class="card-body">
<p>@logic.playerTieLogic.currentTiePlayer() is currently picking his number for the cut.</p> <div class="mb-3">
<p>Currently picked Cards:</p> <p class="card-text">
<div id="cardsplayed"> The last round was tied between:
@for((player, card) <- logic.playerTieLogic.getSelectedCard) { <span class="ms-1">
<div id="playedcardplayer"> @for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) {
<p>@player</p> <span class="badge text-bg-secondary me-1">@players</span>
@util.WebUIUtils.cardtoImage(card) }
</div> </span>
} </p>
</div> </div>
}
@if(player.equals(gamelobby.logic.playerTieLogic.currentTiePlayer())) {
@defining(gamelobby.logic.playerTieLogic.highestAllowedNumber()) { maxNum =>
<div class="alert alert-info" role="alert" aria-live="polite">
Pick a number between 1 and @{
maxNum + 1
}.
The resulting card will be your card for the cut.
</div>
<div class="row g-2 align-items-center">
<div class="col-auto">
<label for="tieNumber" class="col-form-label">Your number</label>
</div>
<div class="col-auto">
<input type="number" id="tieNumber" class="form-control" name="tie" min="1" max="@{
maxNum + 1
}" placeholder="1" required>
</div>
<div class="col-auto">
<button onclick="selectTie('@gamelobby.id')" class="btn btn-primary">
Confirm</button>
</div>
</div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6">
<div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p>
<div class="card-img-top">
@util.WebUIUtils.cardtoImage(card)
</div>
</div>
</div>
</div>
}
} else {
<div class="col-12">
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>
No cards have been selected yet.
</div>
</div>
}
</div>
}
} else {
<div class="alert alert-warning" role="alert" aria-live="polite">
<strong>@gamelobby.logic.playerTieLogic.currentTiePlayer()</strong>
is currently picking a number for the cut.
</div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p>
<div class="card-img-top">
@util.WebUIUtils.cardtoImage(card)
</div>
</div>
</div>
</div>
}
} else {
<div class="col-12">
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>
No cards have been selected yet.
</div>
</div>
}
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
} <script>
connectWebSocket()
</script>
@@ -1,82 +1,102 @@
@(user: Option[model.users.User], gamelobby: logic.game.GameLobby) @(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
@main("Lobby") { <main class="lobby-background vh-100" id="lobbybackground">
<div class="container"> <!-- Kick Modal -->
<div class="row"> <div class="modal fade" data-backdrop="static" data-keyboard="false" data-focus="true" id="kickedModal" tabindex="-1" role="dialog" aria-labelledby="kickedModalTitle">
<div class="col"> <div class="modal-dialog modal-dialog-centered" role="document">
<div class="p-3 fs-1 d-flex align-items-center"> <div class="modal-content">
<div class="text-center" style="flex-grow: 1;"> <div class="modal-header">
Lobby-Name: @gamelobby.name <h5 class="modal-title" id="kickedModalTitle">Kicked</h5>
</div>
<div class="modal-body">
<p>You've been kicked from the lobby.</p>
</div> </div>
<form action="@(routes.IngameController.leaveGame(gamelobby.id))">
<button type="submit" class="btn btn-danger ms-auto">Exit</button>
</form>
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div class="col"> <!-- Session Closed Modal -->
<div class="p-3 text-center fs-4">Playeramount: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div> <div class="modal fade" data-backdrop="static" data-keyboard="false" data-focus="true" id="sessionClosed" tabindex="-1" role="dialog" aria-labelledby="sessionClosedModalTitle">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sessionClosedModalTitle">Session Closed</h5>
</div>
<div class="modal-body">
<p>The session was closed.</p>
</div>
</div>
</div> </div>
</div> </div>
<div class="row justify-content-center">
<!-- Lobby -->
<div class="container d-flex flex-column" style="height: calc(100vh - 1rem);">
<div class="row">
<div class="col">
<div class="p-3 fs-1 d-flex align-items-center">
<div class="text-center" style="flex-grow: 1;">
Lobby-Name: @gamelobby.name
</div>
<div class="btn btn-danger ms-auto" onclick="leaveGame('@gamelobby.id')">Exit</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="p-3 text-center fs-4" id="playerAmount">
Players: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div>
</div>
</div>
<div class="row justify-content-center align-items-center flex-grow-1">
@if((gamelobby.getUserSession(user.get.id).host)) { @if((gamelobby.getUserSession(user.get.id).host)) {
<div id="players" class="justify-content-center align-items-center d-flex">
@for(playersession <- gamelobby.getPlayers.values) { @for(playersession <- gamelobby.getPlayers.values) {
<div class="col-auto"> <div class="col-auto my-auto m-3">
<div class="card" style="width: 18rem;"> <div class="card" style="width: 18rem;">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="card-img-top w-50 mx-auto mt-3" /> <img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body"> <div class="card-body">
@if(playersession.id == user.get.id) { @if(playersession.id == user.get.id) {
<h5 class="card-title">@playersession.name (You)</h5> <h5 class="card-title">@playersession.name (You)</h5>
@* <p class="card-text">Your text could be here!</p>*@ <a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a>
<a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a> } else {
} else { <h5 class="card-title">@playersession.name</h5>
<h5 class="card-title">@playersession.name</h5> <div class="btn btn-danger" onclick="handleKickPlayer('@playersession.id')">
@* <p class="card-text">Your text could be here!</p>*@ Remove</div>
<form action="@(routes.IngameController.kickPlayer(gamelobby.id, playersession.id))" method="post"> }
<button type="submit" class="btn btn-danger">Remove</button>
</form>
}
</div> </div>
</div> </div>
</div> </div>
} }
<div class="row"> </div>
<div class="col text-center mt-3"> <div class="col-12 text-center mb-5">
<a href="@(routes.IngameController.startGame(gamelobby.id))" class="btn btn-success">Start Game</a> <div class="btn btn-success" onclick="startGame()">Start Game</div>
</div>
</div> </div>
} else { } else {
<div id="players" class="justify-content-center align-items-center d-flex">
@for(playersession <- gamelobby.getPlayers.values) { @for(playersession <- gamelobby.getPlayers.values) {
<div class="col-auto"> <div class="col-auto my-auto m-3"> <div class="card" style="width: 18rem;">
<div class="card" style="width: 18rem;">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="card-img-top w-50 mx-auto mt-3" /> <img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body"> <div class="card-body">
@if(playersession.id == user.get.id) { @if(playersession.id == user.get.id) {
<h5 class="card-title">@playersession.name (You)</h5> <h5 class="card-title">@playersession.name (You)</h5>
} else { } else {
<h5 class="card-title">@playersession.name</h5> <h5 class="card-title">@playersession.name</h5>
} }
</div> </div>
</div> </div>
</div> </div>
} }
<div class="row">
<div class="col mt-3">
<p class="text-center fs-4">Waiting for the host to start the game...</p>
</div>
</div> </div>
<div class="row"> <div class="col-12 text-center mt-3">
<div class="col mt-1"> <p class="fs-4">Waiting for the host to start the game...</p>
<div class="text-center"> <div class="spinner-border mt-1" role="status">
<div class="spinner-border" role="status"> <span class="visually-hidden">Loading...</span>
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div> </div>
</div> </div>
} }
</div>
</div> </div>
</div> </main>
} <script>
connectWebSocket()
</script>
@@ -1,47 +1,43 @@
@() @()
<div class="login-box">
<div class="card login-card p-4">
<div class="card-body">
<h3 class="text-center mb-4 text-body">Login</h3>
<form onsubmit="login(); return false;">
<div class="mb-3">
<label for="username" class="form-label text-body">Username</label>
<input type="text" class="form-control text-body" id="username" name="username" placeholder="Enter Username" required>
</div>
@main("Login") { <div class="mb-3">
<label for="password" class="form-label text-body">Password</label>
<input type="password" class="form-control text-body" id="password" name="password" placeholder="Enter password" required>
</div>
<div class="login-box"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="card login-card p-4"> <a href="#" class="text-decoration-none">Forgot password?</a>
<div class="card-body"> </div>
<h3 class="text-center mb-4 text-body">Login</h3>
<form action="@routes.UserController.login_Post()" method="post"> <div class="d-grid" >
<div class="mb-3"> <button type="submit" class="btn btn-primary">Login</button>
<label for="username" class="form-label text-body">Username</label> </div>
<input type="text" class="form-control text-body" id="username" name="username" placeholder="Enter Username" required> </form>
</div>
<div class="mb-3"> <p class="text-center mt-3">
<label for="password" class="form-label text-body">Password</label> Dont have an account?
<input type="password" class="form-control text-body" id="password" name="password" placeholder="Enter password" required> <a href="#" class="text-decoration-none">Sign up</a>
</div> </p>
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="#" class="text-decoration-none">Forgot password?</a>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
<p class="text-center mt-3">
Dont have an account?
<a href="#" class="text-decoration-none">Sign up</a>
</p>
</div>
</div> </div>
</div> </div>
<script src="@routes.Assets.versioned("/javascripts/particles.js")"></script> </div>
<script> <script src="@routes.Assets.versioned("/javascripts/particles.js")"></script>
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() { <script>
console.log('callback - particles.js config loaded'); particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
}); console.log('callback - particles.js config loaded');
</script> });
<div id="particles-js" style="background-color: rgb(11, 8, 8); disconnectWebSocket();
background-size: cover; </script>
background-repeat: no-repeat; <div id="particles-js" style="background-color: rgb(11, 8, 8);
background-position: 50% 50%;"></div> background-size: cover;
} background-repeat: no-repeat;
background-position: 50% 50%;"></div>
+15 -15
View File
@@ -1,8 +1,8 @@
@* @*
* This template is called from the `index` template. This template * This template is called from the `index` template. This template
* handles the rendering of the page header and body tags. It takes * handles the rendering of the page header and body tags. It takes
* two arguments, a `String` for the title of the page and an `Html` * two arguments, a `String` for the title of the page and an `Html`
* object to insert into the body of the page. * object to insert into the body of the page.
*@ *@
@(title: String)(content: Html) @(title: String)(content: Html)
@@ -18,17 +18,17 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head> </head>
<body class="d-flex flex-column min-vh-100 game-field-background"> <script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<main> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
@* And here's where we render the `Html` object containing <script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
* the page content. *@ <script src="@routes.Assets.versioned("javascripts/websocket.js")" type="text/javascript"></script>
@content <script src="@routes.Assets.versioned("javascripts/events.js")" type="text/javascript"></script>
</main> <script src="@routes.Assets.versioned("javascripts/interact.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
<footer class="footer"> <body class="d-flex flex-column min-vh-100" id="main-body">
</footer> @* And here's where we render the `Html` object containing
* the page content. *@
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script> @content
<script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
</body> </body>
</html> </html>
@@ -1,9 +1,8 @@
@(user: Option[model.users.User]) @(user: Option[model.users.User])
@main("Create Game") {
@navbar(user) @navbar(user)
<form action="@routes.MainMenuController.createGame()" method="post" class="game-field-background"> <main class="lobby-background flex-grow-1">
<div class="w-50 mx-auto"> <div class="w-25 mx-auto">
<div class="mt-3"> <div class="mt-3">
<label for="lobbyname" class="form-label">Lobby-Name</label> <label for="lobbyname" class="form-label">Lobby-Name</label>
<input type="text" class="form-control" id="lobbyname" name="lobbyname" placeholder="Lobby 1" required> <input type="text" class="form-control" id="lobbyname" name="lobbyname" placeholder="Lobby 1" required>
@@ -12,21 +11,23 @@
<input class="form-check-input" type="checkbox" id="visibilityswitch" disabled> <input class="form-check-input" type="checkbox" id="visibilityswitch" disabled>
<label class="form-check-label" for="visibilityswitch">public/private</label> <label class="form-check-label" for="visibilityswitch">public/private</label>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label for="playeramount" class="form-label">Playeramount:</label> <label for="playeramount" class="form-label">Playeramount:</label>
<input type="range" class="form-range text-body" min="2" max="7" value="2" id="playeramount" name="playeramount"> <input type="range" class="form-range text-body" min="2" max="7" value="2" id="playeramount" name="playeramount">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<span>2</span> <span>2</span>
<span>3</span> <span>3</span>
<span>4</span> <span>4</span>
<span>5</span> <span>5</span>
<span>6</span> <span>6</span>
<span>7</span> <span>7</span>
</div>
</div> </div>
</div>
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<button type="submit" class="btn btn-success">Create Game</button> <div class="btn btn-success" onclick="createGameJS()">Create Game</div>
</div> </div>
</div> </div>
</form> </main>
} <script>
disconnectWebSocket();
</script>
@@ -1,57 +1,61 @@
@(user: Option[model.users.User]) @(user: Option[model.users.User])
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary navbar-drop-shadow">
<div class="container d-flex justify-content-left"> <div class="container d-flex justify-content-start">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navBar" aria-controls="navBar" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navBar" aria-controls="navBar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse justify-content-center" id="navBar"> <div class="collapse navbar-collapse justify-content-center" id="navBar">
<a class="navbar-brand ms-auto" href="@routes.MainMenuController.mainMenu()"> <a class="navbar-brand ms-auto" href="@routes.MainMenuController.mainMenu()">
<img src="@routes.Assets.versioned("images/logo.png")" alt="Logo" width="30" height="20" class="d-inline-block align-text-top"> <img src="@routes.Assets.versioned("images/logo.png")" alt="Logo" width="30" height="20" class="d-inline-block align-text-top">
KnockOutWhist KnockOutWhist
</a> </a>
<div class="navbar-nav me-auto mb-2 mb-lg-0"> <div class="navbar-nav me-auto mb-2 mb-lg-0">
<ul class="navbar-nav mb-2 mb-lg-0"> <ul class="navbar-nav mb-2 mb-lg-0">
@if(user.isDefined) { @if(user.isDefined) {
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="@routes.MainMenuController.mainMenu()">Create Game</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" aria-disabled="true">Lobbies</a>
</li>
}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="@routes.MainMenuController.rules()">Rules</a> <a class="nav-link active" aria-current="page" onclick="navSpa('0', 'Knockout Whist - Create Game'); return false;" href="@routes.MainMenuController.mainMenu()">
Create Game</a>
</li> </li>
</ul> <li class="nav-item">
<form class="navbar-nav me-auto mb-2 mb-lg-0" method="post" action="@routes.MainMenuController.joinGame()"> <a class="nav-link disabled" aria-disabled="true">Lobbies</a>
<input class="form-control me-2" type="text" placeholder="Enter GameCode" name="gameId" aria-label="Join Game"/>
<button class="btn btn-outline-success" type="submit">Join</button>
</form>
</div>
@* Right side: profile dropdown if logged in, otherwise Login / Sign Up buttons *@
@if(user.isDefined) {
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="profileDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="rounded-circle" width="30" height="30" />
<span class="ms-2">@user.get.name</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="profileDropdown">
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">Stats</a></li>
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@routes.UserController.logout()">Logout</a></li>
</ul>
</li> </li>
</ul> }
} else { <li class="nav-item">
<div class="d-flex ms-auto"> <a class="nav-link active" onclick="navSpa('1', 'Knockout Whist - Rules'); return false;" href="@routes.MainMenuController.rules()">
<a class="btn btn-outline-primary me-2" href="@routes.UserController.login()">Login</a> Rules</a>
<a class="btn btn-primary" href="@routes.UserController.login()">Sign Up</a> </li>
</div> </ul>
} <form class="navbar-nav me-auto mb-2 mb-lg-0" onsubmit="joinGame(); return false;">
<input class="form-control me-2" type="text" placeholder="Enter GameCode" id="gameId" aria-label="Join Game"/>
<button class="btn btn-outline-success" type="submit">Join</button>
</form>
</div> </div>
@* Right side: profile dropdown if logged in, otherwise Login / Sign Up buttons *@
@if(user.isDefined) {
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="profileDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="rounded-circle" width="30" height="30" />
<span class="ms-2">@user.get.name</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="profileDropdown">
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">
Stats</a></li>
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">
Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@routes.UserController.logout()">Logout</a></li>
</ul>
</li>
</ul>
} else {
<div class="d-flex ms-auto">
<a class="btn btn-outline-primary me-2" href="@routes.UserController.login()">Login</a>
<a class="btn btn-primary" href="@routes.UserController.login()">Sign Up</a>
</div>
}
</div> </div>
</nav> </div>
</nav>
@@ -1,76 +1,180 @@
@(user: Option[model.users.User]) @(user: Option[model.users.User])
@navbar(user)
@main("Rules") { <main class="lobby-background flex-grow-1">
@navbar(user) <div class="container my-4" style="max-width: 980px;">
<div id="rules"> <div class="card rules-card shadow-sm rounded-3 overflow-hidden">
<div class="container my-4"> <div class="card-header text-center py-3 border-0">
<div class="card shadow-sm rounded-3"> <h3 class="mb-0 rules-title">Game Rules Overview</h3>
<div class="card-header text-white text-center"> </div>
<h4 class="mb-0 text-body">Game Rules Overview</h4>
</div> <div class="card-body p-0">
<div class="card-body p-0"> <style>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0 align-middle"> </style>
<thead class="table-dark">
<tr> <div class="accordion rules-accordion" id="rulesAccordion">
<th scope="col">Section</th> <div class="accordion-item">
<th scope="col">Details</th> <h2 class="accordion-header" id="headingPlayers">
</tr> <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePlayers" aria-expanded="true" aria-controls="collapsePlayers">
</thead> Players
<tbody> </button>
<tr> </h2>
<td>Players</td> <div id="collapsePlayers" class="accordion-collapse collapse show" aria-labelledby="headingPlayers" data-bs-parent="#rulesAccordion">
<td>Two to seven players. The aim is to be the last player left in the game.</td> <div class="accordion-body">
</tr> Two to seven players. The aim is to be the last player left in the game.
<tr> </div>
<td>Aim</td> </div>
<td>To be the last player left at the end of the game, with the object in each hand being to win a majority of tricks.</td> </div>
</tr>
<tr> <div class="accordion-item">
<td>Equipment</td> <h2 class="accordion-header" id="headingAim">
<td>A standard 52-card pack is used.</td> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseAim" aria-expanded="false" aria-controls="collapseAim">
</tr> Aim
<tr> </button>
<td>Card Ranks</td> </h2>
<td>In each suit, cards rank from highest to lowest: A K Q J 10 9 8 7 6 5 4 3 2.</td> <div id="collapseAim" class="accordion-collapse collapse" aria-labelledby="headingAim" data-bs-parent="#rulesAccordion">
</tr> <div class="accordion-body">
<tr> To be the last player left at the end of the game, with the object in each hand being to win a majority of tricks.
<td>Deal (First Hand)</td> </div>
<td>The dealer deals seven cards to each player. One card is turned up to determine the trump suit for the round.</td> </div>
</tr> </div>
<tr>
<td>Deal (Subsequent Hands)</td> <div class="accordion-item">
<td>The deal rotates clockwise. The player who took the most tricks in the previous hand selects the trump suit. If there's a tie, players cut cards to decide who calls trumps. One fewer card is dealt in each successive hand until the final hand consists of one card each.</td> <h2 class="accordion-header" id="headingEquipment">
</tr> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseEquipment" aria-expanded="false" aria-controls="collapseEquipment">
<tr> Equipment
<td>Play</td> </button>
<td>The player to the dealer's left (eldest hand) leads the first trick. Any card can be led. Other players must follow suit if possible. A player with no cards of the suit led may play any card.</td> </h2>
</tr> <div id="collapseEquipment" class="accordion-collapse collapse" aria-labelledby="headingEquipment" data-bs-parent="#rulesAccordion">
<tr> <div class="accordion-body">
<td>Winning a Trick</td> A standard 52-card pack is used.
<td>The highest card of the suit led wins, unless a trump is played, in which case the highest trump wins. The winner of the trick leads the next.</td> </div>
</tr> </div>
<tr> </div>
<td>Leading Trumps</td>
<td>Some rules disallow leading trumps before the suit has been 'broken' (a trump has been played to the lead of another suit). Leading trumps is always permissible if a player holds only trumps.</td> <div class="accordion-item">
</tr> <h2 class="accordion-header" id="headingRanks">
<tr> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseRanks" aria-expanded="false" aria-controls="collapseRanks">
<td>Knockout</td> Card Ranks
<td>At the end of each hand, any player who took no tricks is knocked out and takes no further part in the game.</td> </button>
</tr> </h2>
<tr> <div id="collapseRanks" class="accordion-collapse collapse" aria-labelledby="headingRanks" data-bs-parent="#rulesAccordion">
<td>Winning the Game</td> <div class="accordion-body">
<td>The game is won when a player takes all the tricks in a round, as all other players are knocked out, leaving only one player remaining.</td> In each suit, cards rank from highest to lowest: A K Q J 10 9 8 7 6 5 4 3 2.
</tr> </div>
<tr> </div>
<td>Dog Life</td> </div>
<td>The first player who takes no tricks is awarded a "dog's life". In the next hand, that player is dealt one card and can decide which trick to play it to. Each time a trick is played the "dog" may either play the card or knock on the table and wait to play it later. If the dog wins a trick, the player to the left leads the next and the dog re-enters the game properly in the next hand. If the dog fails, they are knocked out.</td>
</tr> <div class="accordion-item">
</tbody> <h2 class="accordion-header" id="headingDealFirst">
</table> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDealFirst" aria-expanded="false" aria-controls="collapseDealFirst">
Deal (First Hand)
</button>
</h2>
<div id="collapseDealFirst" class="accordion-collapse collapse" aria-labelledby="headingDealFirst" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The dealer deals seven cards to each player. One card is turned up to determine the trump suit for the round.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDealSubsequent">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDealSubsequent" aria-expanded="false" aria-controls="collapseDealSubsequent">
Deal (Subsequent Hands)
</button>
</h2>
<div id="collapseDealSubsequent" class="accordion-collapse collapse" aria-labelledby="headingDealSubsequent" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The deal rotates clockwise. The player who took the most tricks in the previous hand selects the trump suit. If there's a tie, players cut cards to decide who calls trumps. One fewer card is dealt in each successive hand until the final hand consists of one card each.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingPlay">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePlay" aria-expanded="false" aria-controls="collapsePlay">
Play
</button>
</h2>
<div id="collapsePlay" class="accordion-collapse collapse" aria-labelledby="headingPlay" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The player to the dealer's left (eldest hand) leads the first trick. Any card can be led. Other players must follow suit if possible. A player with no cards of the suit led may play any card.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingWinningTrick">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseWinningTrick" aria-expanded="false" aria-controls="collapseWinningTrick">
Winning a Trick
</button>
</h2>
<div id="collapseWinningTrick" class="accordion-collapse collapse" aria-labelledby="headingWinningTrick" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The highest card of the suit led wins, unless a trump is played, in which case the highest trump wins. The winner of the trick leads the next.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingLeadingTrumps">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLeadingTrumps" aria-expanded="false" aria-controls="collapseLeadingTrumps">
Leading Trumps
</button>
</h2>
<div id="collapseLeadingTrumps" class="accordion-collapse collapse" aria-labelledby="headingLeadingTrumps" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
Some rules disallow leading trumps before the suit has been 'broken' (a trump has been played to the lead of another suit). Leading trumps is always permissible if a player holds only trumps.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingKnockout">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseKnockout" aria-expanded="false" aria-controls="collapseKnockout">
Knockout
</button>
</h2>
<div id="collapseKnockout" class="accordion-collapse collapse" aria-labelledby="headingKnockout" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
At the end of each hand, any player who took no tricks is knocked out and takes no further part in the game.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingWinningGame">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseWinningGame" aria-expanded="false" aria-controls="collapseWinningGame">
Winning the Game
</button>
</h2>
<div id="collapseWinningGame" class="accordion-collapse collapse" aria-labelledby="headingWinningGame" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The game is won when a player takes all the tricks in a round, as all other players are knocked out, leaving only one player remaining.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDogLife">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDogLife" aria-expanded="false" aria-controls="collapseDogLife">
Dog Life
</button>
</h2>
<div id="collapseDogLife" class="accordion-collapse collapse" aria-labelledby="headingDogLife" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The first player who takes no tricks is awarded a \"dog's life\". In the next hand, that player is dealt one card and can decide which trick to play it to. Each time a trick is played the dog may either play the card or knock on the table and wait to play it later. If the dog wins a trick, the player to the left leads the next and the dog re-enters the game properly in the next hand. If the dog fails, they are knocked out.
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </main>
</div> <script>
} disconnectWebSocket();
</script>
+37 -35
View File
@@ -5,46 +5,48 @@
<!DOCTYPE configuration> <!DOCTYPE configuration>
<configuration> <configuration>
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/> <import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder" />
<import class="ch.qos.logback.classic.AsyncAppender"/> <import class="ch.qos.logback.classic.AsyncAppender" />
<import class="ch.qos.logback.core.FileAppender"/> <import class="ch.qos.logback.core.FileAppender" />
<import class="ch.qos.logback.core.ConsoleAppender"/> <import class="ch.qos.logback.core.ConsoleAppender" />
<appender name="FILE" class="FileAppender"> <appender name="FILE" class="FileAppender">
<file>${application.home:-.}/logs/application.log</file> <file>${application.home:-.}/logs/application.log</file>
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<charset>UTF-8</charset> <charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n</pattern> <pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n
</encoder> </pattern>
</appender> </encoder>
</appender>
<appender name="STDOUT" class="ConsoleAppender"> <appender name="STDOUT" class="ConsoleAppender">
<!-- <!--
On Windows, enabling Jansi is recommended to benefit from color code interpretation on DOS command prompts, On Windows, enabling Jansi is recommended to benefit from color code interpretation on DOS command prompts,
which otherwise risk being sent ANSI escape sequences that they cannot interpret. which otherwise risk being sent ANSI escape sequences that they cannot interpret.
See https://logback.qos.ch/manual/layouts.html#coloring See https://logback.qos.ch/manual/layouts.html#coloring
--> -->
<!-- <withJansi>true</withJansi> --> <!-- <withJansi>true</withJansi> -->
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<charset>UTF-8</charset> <charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n</pattern> <pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n
</encoder> </pattern>
</appender> </encoder>
</appender>
<appender name="ASYNCFILE" class="AsyncAppender"> <appender name="ASYNCFILE" class="AsyncAppender">
<appender-ref ref="FILE"/> <appender-ref ref="FILE" />
</appender> </appender>
<appender name="ASYNCSTDOUT" class="AsyncAppender"> <appender name="ASYNCSTDOUT" class="AsyncAppender">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT" />
</appender> </appender>
<logger name="play" level="INFO"/> <logger name="play" level="INFO" />
<logger name="application" level="DEBUG"/> <logger name="application" level="DEBUG" />
<root level="WARN"> <root level="WARN">
<appender-ref ref="ASYNCFILE"/> <appender-ref ref="ASYNCFILE" />
<appender-ref ref="ASYNCSTDOUT"/> <appender-ref ref="ASYNCSTDOUT" />
</root> </root>
</configuration> </configuration>
+16 -16
View File
@@ -3,28 +3,28 @@
# https://www.playframework.com/documentation/latest/ScalaRouting # https://www.playframework.com/documentation/latest/ScalaRouting
# ~~~~ # ~~~~
# For the javascript routing
GET /assets/js/routes controllers.JavaScriptRoutingController.javascriptRoutes()
# Primary routes # Primary routes
GET / controllers.MainMenuController.index() GET / controllers.MainMenuController.index()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# Main menu routes # Main menu routes
GET /mainmenu controllers.MainMenuController.mainMenu() GET /mainmenu controllers.MainMenuController.mainMenu()
GET /rules controllers.MainMenuController.rules() GET /rules controllers.MainMenuController.rules()
GET /navSPA/:pType controllers.MainMenuController.navSPA(pType)
POST /createGame controllers.MainMenuController.createGame() POST /createGame controllers.MainMenuController.createGame()
POST /joinGame controllers.MainMenuController.joinGame() POST /joinGame controllers.MainMenuController.joinGame()
# User authentication routes # User authentication routes
GET /login controllers.UserController.login() GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post() POST /login controllers.UserController.login_Post()
GET /logout controllers.UserController.logout() GET /logout controllers.UserController.logout()
# In-game routes # In-game routes
GET /game/:id controllers.IngameController.game(id: String) GET /game/:id controllers.IngameController.game(id: String)
GET /game/:id/join controllers.IngameController.joinGame(id: String)
GET /game/:id/start controllers.IngameController.startGame(id: String) # Websocket
POST /game/:id/kickPlayer controllers.IngameController.kickPlayer(id: String, playerId: java.util.UUID) GET /websocket controllers.WebsocketController.socket()
GET /game/:id/leaveGame controllers.IngameController.leaveGame(id: String)
POST /game/:id/playCard controllers.IngameController.playCard(id: String)
@@ -0,0 +1,333 @@
var canPlayCard = false;
function alertMessage(message) {
let newHtml = '';
const alertId = `alert-${Date.now()}`;
const fadeTime = 500;
const duration = 5000;
newHtml += `
<div class="fixed-top d-flex justify-content-center mt-3" style="z-index: 1050;">
<div
id="${alertId}" class="alert alert-primary d-flex align-items-center p-2 mb-0 w-auto" role="alert" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi flex-shrink-0 me-2" viewBox="0 0 16 16" role="img" aria-label="Warning:">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div class="small">
<small>${message}</small>
</div>
</div>
</div>
`;
$('#main-body').prepend(newHtml);
const $notice = $(`#${alertId}`);
$notice.fadeIn(fadeTime);
setTimeout(function() {
$notice.fadeOut(fadeTime, function() {
$(this).parent().remove();
});
}, duration);
}
function receiveHandEvent(eventData) {
//Data
const dog = eventData.dog;
const hand = eventData.hand;
const handElement = $('#card-slide');
handElement.addClass('ingame-cards-slide')
let newHtml = '';
//Build Hand Container
hand.forEach((card) => {
//Data
const idx = card.idx;
const cardS = card.card;
const cardHtml = `
<div class="col-auto handcard" style="border-radius: 6px">
<div class="btn btn-outline-light p-0 border-0 shadow-none"
data-card-id="${idx}"
style="border-radius: 6px"
onclick="handlePlayCard(this, '${dog}')">
<img src="/assets/images/cards/${cardS}.png" width="120px" style="border-radius: 6px" alt="${cardS}"/>
</div>
</div>
`;
newHtml += cardHtml;
});
//Build dog if needed
if (dog) {
newHtml += `
<div class="mt-2">
<button class="btn btn-danger" onclick="handleSkipDogLife(this)">Skip Turn</button>
</div>
`;
}
handElement.html(newHtml);
}
function newRoundEvent(eventData) {
const trumpsuit = eventData.trumpsuit;
const players = eventData.players;
const tableElement = $('#score-table-body');
let tablehtml = `
<h4 class="fw-bold mb-3 text-black">Tricks Won</h4>
<div class="d-flex justify-content-between score-header pb-1">
<div style="width: 50%">PLAYER</div>
<div style="width: 50%">TRICKS</div>
</div>
`;
players.forEach(
tablehtml += `
<div class="d-flex justify-content-between score-row pt-1">
<div style="width: 50%" class="text-truncate">'${players}'</div>
<div style="width: 50%">
0
</div>
</div>
`
);
tableElement.html(tablehtml);
const trumpsuitClass = $('#trumpsuit');
trumpsuitClass.html(trumpsuit);
}
function trickEndEvent(eventData) {
const winner = eventData.playerwon;
const players = eventData.playersin;
const tricklist = eventData.tricklist;
let newHtml = '';
let tricktable = $('#score-table-body');
newHtml += `
<h4 class="fw-bold mb-3 text-black">Tricks Won</h4>
<div class="d-flex justify-content-between score-header pb-1">
<div style="width: 50%">PLAYER</div>
<div style="width: 50%">TRICKS</div>
</div>
`;
let playercounts = new Map();
players.forEach( player => {
playercounts.set(player, 0)
});
tricklist.forEach( player => {
if ( player !== "Trick in Progress" && playercounts.has(player)) {
playercounts.set(player, playercounts.get(player) + 1)
}
}
)
const playerorder = players.sort((playerA, playerB) => {
const countA = playercounts.get(playerA) || 0;
const countB = playercounts.get(playerB) || 0;
return countB - countA;
});
playerorder.forEach( player => {
newHtml += `
<div class="d-flex justify-content-between score-row pt-1">
<div style="width: 50%" class="text-truncate">${player}</div>
<div style="width: 50%">
${playercounts.get(player)}
</div>
</div>
`
});
alertMessage(`${winner} won the trick!`)
tricktable.html(newHtml);
}
function newTrickEvent() {
const firstCardContainer = $('#first-card-container');
const emptyHtml = '';
let newHtml = '';
newHtml += `
<img src="/assets/images/cards/1B.png" alt="Blank Card" width="80px" style="border-radius: 6px"/>
`;
firstCardContainer.html(newHtml);
const playedCardsContainer = $('#trick-cards-container')
playedCardsContainer.html(emptyHtml)
}
function requestCardEvent(eventData) {
const player = eventData.player;
const handElement = $('#card-slide')
handElement.removeClass('inactive');
canPlayCard = true;
}
function receiveGameStateChange(eventData) {
const content = eventData.content;
const title = eventData.title || 'Knockout Whist';
const url = eventData.url || null;
exchangeBody(content, title, url);
}
function receiveCardPlayedEvent(eventData) {
const firstCard = eventData.firstCard;
const playedCards = eventData.playedCards;
const trickCardsContainer = $('#trick-cards-container');
const firstCardContainer = $('#first-card-container')
let trickHTML = '';
playedCards.forEach(cardCombo => {
trickHTML += `
<div class="col-auto">
<div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem; backdrop-filter: blur(4px);">
<div class="p-2">
<img src="/assets/images/cards/${cardCombo.cardId}.png" width="100%" alt="${cardCombo.cardId}"/>
</div>
<div class="card-body p-2 bg-transparent">
<small class="fw-semibold text-secondary">${cardCombo.player}</small>
</div>
</div>
</div>
`;
});
trickCardsContainer.html(trickHTML);
let altText;
let imageSrc;
if (firstCard === "BLANK") {
imageSrc = "/assets/images/cards/1B.png";
altText = "Blank Card";
} else {
imageSrc = `/assets/images/cards/${firstCard}.png`;
altText = `Card ${firstCard}`;
}
const newFirstCardHTML = `
<img src="${imageSrc}" alt="${altText}" width="80px" style="border-radius: 6px"/>
`;
firstCardContainer.html(newFirstCardHTML);
}
function receiveLobbyUpdateEvent(eventData) {
const host = eventData.host;
const maxPlayers = eventData.maxPlayers;
const players = eventData.players;
const lobbyPlayersContainer = $('#players');
const playerAmountBox = $('#playerAmount');
let newHtml = ''
if (host) {
players.forEach(user => {
const inner = user.self ? `<h5 class="card-title">${user.name} (You)</h5>
<a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a>`
: ` <h5 class="card-title">${user.name}</h5>
<div class="btn btn-danger" onclick="handleKickPlayer('${user.id}')">Remove</div>`
newHtml += `<div class="col-auto my-auto m-3">
<div class="card" style="width: 18rem;">
<img src="/assets/images/profile.png" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body">
${inner}
</div>
</div>
</div>`
})
} else {
players.forEach(user => {
const inner = user.self ? `<h5 class="card-title">${user.name} (You)</h5>` : ` <h5 class="card-title">${user.name}</h5>`
newHtml += `<div class="col-auto my-auto m-3">
<div class="card" style="width: 18rem;">
<img src="/assets/images/profile.png" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body">
${inner}
</div>
</div>
</div>`
})
}
lobbyPlayersContainer.html(newHtml);
playerAmountBox.text(`Players: ${players.length} / ${maxPlayers}`);
}
function receiveKickEvent(eventData) {
$('#kickedModal').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
setTimeout(() => {
receiveGameStateChange(eventData)
}, 5000);
}
function receiveSessionClosedEvent(eventData) {
$('#sessionClosed').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
setTimeout(() => {
receiveGameStateChange(eventData)
}, 5000);
}
function receiveTurnEvent(eventData) {
const currentPlayer = eventData.currentPlayer;
const nextPlayers = eventData.nextPlayers;
const currentPlayerNameContainer = $('#current-player-name');
const nextPlayersContainer = $('#next-players-container');
const nextPlayerText = $('#next-players-text');
let currentPlayerName = currentPlayer.name;
if (currentPlayer.dog) {
currentPlayerName += " 🐶";
}
currentPlayerNameContainer.text(currentPlayerName);
if (nextPlayers.length === 0) {
nextPlayerText.hide();
nextPlayersContainer.html('');
} else {
console.log("Length"+nextPlayers.length);
nextPlayerText.show();
let nextPlayersHtml = '';
nextPlayers.forEach((player) => {
let playerName = player.name;
if (player.dog) {
playerName += " 🐶";
}
nextPlayersHtml += `<p class="fs-5 text-primary">${playerName}</p>`;
});
nextPlayersContainer.html(nextPlayersHtml);
}
}
function receiveRoundEndEvent(eventData) {
const player = eventData.player
const tricks = eventData.tricks
alertMessage(`${player} won this round with ${tricks} tricks!`)
}
onEvent("ReceivedHandEvent", receiveHandEvent)
onEvent("GameStateChangeEvent", receiveGameStateChange)
onEvent("NewRoundEvent", newRoundEvent)
onEvent("TrickEndEvent", trickEndEvent)
onEvent("NewTrickEvent", newTrickEvent)
onEvent("RequestCardEvent", requestCardEvent)
onEvent("CardPlayedEvent", receiveCardPlayedEvent)
onEvent("LobbyUpdateEvent", receiveLobbyUpdateEvent)
onEvent("LeftEvent", receiveGameStateChange)
onEvent("KickEvent", receiveKickEvent)
onEvent("SessionClosed", receiveSessionClosedEvent)
onEvent("TurnEvent", receiveTurnEvent)
onEvent("RoundEndEvent", receiveRoundEndEvent)
globalThis.alertMessage = alertMessage
@@ -0,0 +1,82 @@
function handlePlayCard(card, dog) {
if(!canPlayCard) return
canPlayCard = false;
const cardId = card.dataset.cardId;
console.debug(`Playing card ${cardId} from hand`)
const wiggleKeyframes = [
{ transform: 'translateX(0)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(5px)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(0)' }
];
const wiggleTiming = {
duration: 400,
iterations: 1,
easing: 'ease-in-out',
fill: 'forwards'
};
const cardslide = $('#card-slide')
const payload = {
cardindex: cardId,
isDog: dog
}
sendEventAndWait("PlayCard", payload).then(
() => {
card.parentElement.remove();
cardslide.find('.handcard').each(function(newIndex) {
const $innerButton = $(this).find('.btn');
$innerButton.attr('data-card-id', newIndex);
const isInDogLife = $innerButton.attr('onclick').includes("'true'") ? 'true' : 'false';
$innerButton.attr('onclick', `handlePlayCard(this, '${isInDogLife}')`);
console.debug(`Re-indexed card: Old index was ${$innerButton.attr('data-card-id')}, New index is ${newIndex}`);
});
cardslide.addClass("inactive")
}
).catch(
(err) => {
canPlayCard = true;
const cardslide = $('#card-slide')
cardslide.removeClass("inactive")
card.parentElement.animate(wiggleKeyframes, wiggleTiming);
alertMessage(err.message)
}
)
}
function handleSkipDogLife(button) {
// TODO needs implementation
}
function startGame() {
sendEvent("StartGame")
}
function handleTrumpSelection(object) {
const $button = $(object);
const trumpIndex = parseInt($button.data('trump'));
const payload = {
suitIndex: trumpIndex
}
sendEvent("PickTrumpsuit", payload)
}
function handleKickPlayer(playerId) {
sendEvent("KickPlayer", {
playerId: playerId
})
}
function handleReturnToLobby() {
sendEvent("ReturnToLobby")
}
+143 -1
View File
@@ -77,4 +77,146 @@
}) })
}) })
}) })
})() })()
function createGameJS() {
let lobbyName = $('#lobbyname').val();
if ($.trim(lobbyName) === "") {
lobbyName = "DefaultLobby"
}
const jsonObj = {
lobbyname: lobbyName,
playeramount: $("#playeramount").val()
}
sendGameCreationRequest(jsonObj);
}
function sendGameCreationRequest(dataObject) {
const route = jsRoutes.controllers.MainMenuController.createGame();
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
data: JSON.stringify(dataObject),
dataType: 'json',
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, "Knockout Whist - Lobby", data.redirectUrl);
}
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
})
}
function exchangeBody(content, title = "Knockout Whist", url = null) {
if (url) {
window.history.pushState({}, title, url);
}
$("#main-body").html(content);
document.title = title;
}
function login() {
const username = $('#username').val();
const password = $('#password').val();
const jsonObj = {
username: username,
password: password
};
const route = jsRoutes.controllers.UserController.login_Post();
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, 'Knockout Whist - Create Game', data.redirectUrl);
return
}
alert('Login failed. Please check your credentials and try again.');
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
});
}
function joinGame() {
const gameId = $('#gameId').val();
const jsonObj = {
gameId: gameId
};
const route = jsRoutes.controllers.MainMenuController.joinGame();
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, "Knockout Whist - Lobby", data.redirectUrl);
return
}
alert('Could not join the game. Please check the Game ID and try again.');
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
});
return false
}
function navSpa(page, title) {
const route = jsRoutes.controllers.MainMenuController.navSPA(page);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, title, data.redirectUrl);
return
}
alert('Could not join the game. Please check the Game ID and try again.');
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
});
return false
}
globalThis.exchangeBody = exchangeBody;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
// javascript
let ws = null; // will be created by connectWebSocket()
const pending = new Map(); // id -> { resolve, reject, timer }
const handlers = new Map(); // eventType -> handler(data) -> (value|Promise)
let timer = null;
// helper to attach message/error/close handlers to a socket
function setupSocketHandlers(socket) {
socket.onmessage = (event) => {
console.debug("SERVER MESSAGE:", event.data);
let msg;
try {
msg = JSON.parse(event.data);
} catch (e) {
console.debug("Non-JSON message from server:", event.data, e);
return;
}
const id = msg.id;
const eventType = msg.event;
const status = msg.status;
const data = msg.data;
if (id && typeof status === "string") {
const entry = pending.get(id);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(id);
if (status === "success") {
entry.resolve(data === undefined ? {} : data);
} else {
entry.reject(new Error(msg.error || "Server returned error"));
}
return;
}
if (id && eventType) {
const handler = handlers.get(eventType);
const sendResponse = (result) => {
const response = {id: id, event: eventType, status: result};
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(response));
} else {
console.warn("Cannot send response, websocket not open");
}
};
if (!handler) {
// no handler: respond with an error object in data so server can fail it
console.warn("No handler for event:", eventType);
sendResponse({error: "No handler for event: " + eventType});
return;
}
try {
Promise.resolve(handler(data === undefined ? {} : data))
.then(_ => sendResponse("success"))
.catch(_ => sendResponse("error"));
} catch (err) {
sendResponse("error");
}
}
};
socket.onerror = (error) => {
console.error("WebSocket Error:", error);
if (timer) clearInterval(timer);
for (const [id, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error("WebSocket error/closed"));
pending.delete(id);
}
if (socket.readyState === WebSocket.OPEN) socket.close(1000, "Unexpected error.");
};
socket.onclose = (event) => {
if (timer) clearInterval(timer);
for (const [id, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error("WebSocket closed"));
pending.delete(id);
}
if (event.wasClean) {
console.log(`Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
console.warn('Connection died unexpectedly.');
}
location.href = "/mainmenu";
};
}
// connect/disconnect helpers
function connectWebSocket(url = null) {
if (!url) {
const loc = window.location;
const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
url = protocol + "//" + loc.host + "/websocket";
}
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
if (ws && ws.readyState === WebSocket.CONNECTING) {
// already connecting - return a promise that resolves on open
return new Promise((resolve, reject) => {
const prevOnOpen = ws.onopen;
const prevOnError = ws.onerror;
ws.onopen = (ev) => {
if (prevOnOpen) prevOnOpen(ev);
resolve();
};
ws.onerror = (err) => {
if (prevOnError) prevOnError(err);
reject(err);
};
});
}
ws = new WebSocket(url);
setupSocketHandlers(ws);
return new Promise((resolve, reject) => {
ws.onopen = () => {
console.log("WebSocket connection established!");
// start heartbeat
timer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
sendEventAndWait("ping", {}).then(
() => console.debug("PING RESPONSE RECEIVED"),
).catch(
(err) => console.warn("PING ERROR:", err.message),
);
console.debug("PING SENT");
}
}, 5000);
resolve();
};
ws.onerror = (err) => {
reject(err);
};
});
}
function disconnectWebSocket(code = 1000, reason = "Client disconnect") {
if (timer) {
clearInterval(timer);
timer = null;
}
if (ws) {
try {
ws.close(code, reason);
} catch (e) {
}
ws = null;
}
}
function sendEvent(eventType, eventData) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn("WebSocket is not open. Unable to send message.");
return;
}
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
const message = {id: id, event: eventType, data: eventData};
ws.send(JSON.stringify(message));
console.debug("SENT:", message);
}
function sendEventAndWait(eventType, eventData, timeoutMs = 10000) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("WebSocket is not open"));
}
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
const message = {id: id, event: eventType, data: eventData};
const p = new Promise((resolve, reject) => {
const timerId = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`No response within ${timeoutMs}ms for id=${id}`));
}
}, timeoutMs);
pending.set(id, {resolve, reject, timer: timerId});
});
ws.send(JSON.stringify(message));
console.debug("SENT (await):", message);
return p;
}
function onEvent(eventType, handler) {
handlers.set(eventType, handler);
}
globalThis.sendEvent = sendEvent;
globalThis.sendEventAndWait = sendEventAndWait;
globalThis.onEvent = onEvent;
globalThis.connectWebSocket = connectWebSocket;
globalThis.disconnectWebSocket = disconnectWebSocket;
globalThis.isWebSocketConnected = () => !!ws && ws.readyState === WebSocket.OPEN;
@@ -13,33 +13,33 @@ import play.api.test.Helpers.*
*/ */
class HomeControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting { class HomeControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting {
// "HomeController GET" should { // "HomeController GET" should {
// //
// "render the index page from a new instance of controller" in { // "render the index page from a new instance of controller" in {
// val controller = new HomeController(stubControllerComponents()) // val controller = new HomeController(stubControllerComponents())
// val home = controller.index().apply(FakeRequest(GET, "/")) // val home = controller.index().apply(FakeRequest(GET, "/"))
// //
// status(home) mustBe OK // status(home) mustBe OK
// contentType(home) mustBe Some("text/html") // contentType(home) mustBe Some("text/html")
// contentAsString(home) must include ("Welcome to Play") // contentAsString(home) must include ("Welcome to Play")
// } // }
// //
// "render the index page from the application" in { // "render the index page from the application" in {
// val controller = inject[HomeController] // val controller = inject[HomeController]
// val home = controller.index().apply(FakeRequest(GET, "/")) // val home = controller.index().apply(FakeRequest(GET, "/"))
// //
// status(home) mustBe OK // status(home) mustBe OK
// contentType(home) mustBe Some("text/html") // contentType(home) mustBe Some("text/html")
// contentAsString(home) must include ("Welcome to Play") // contentAsString(home) must include ("Welcome to Play")
// } // }
// //
// "render the index page from the router" in { // "render the index page from the router" in {
// val request = FakeRequest(GET, "/") // val request = FakeRequest(GET, "/")
// val home = route(app, request).get // val home = route(app, request).get
// //
// status(home) mustBe OK // status(home) mustBe OK
// contentType(home) mustBe Some("text/html") // contentType(home) mustBe Some("text/html")
// contentAsString(home) must include ("Welcome to Play") // contentAsString(home) must include ("Welcome to Play")
// } // }
// } // }
} }
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=1 MAJOR=4
MINOR=0 MINOR=6
PATCH=2 PATCH=2