diff --git a/knockoutwhist b/knockoutwhist index 5aa1cef..a5dcf3e 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit 5aa1cef35689d2df8a89e2d8864fc5fcf9c30e33 +Subproject commit a5dcf3ee904ab548479e23ca7b146df14a835b80 diff --git a/knockoutwhistweb/app/assets/stylesheets/main.less b/knockoutwhistweb/app/assets/stylesheets/main.less index 447f7c9..5179c42 100644 --- a/knockoutwhistweb/app/assets/stylesheets/main.less +++ b/knockoutwhistweb/app/assets/stylesheets/main.less @@ -22,6 +22,7 @@ 0% { transform: translateX(-100vw); } 100% { transform: translateX(0); } } + .game-field-background { background-image: @background-image; max-width: 1400px; @@ -33,10 +34,6 @@ width: 100%; height: 100vh; } -.lobby-background { - background-color: @background-color; - -} .navbar-header{ text-align:center; @@ -192,11 +189,6 @@ body { font-size: 20px; } -#trumpsuit { - display: flex; - flex-direction: row; - margin-left: 4%; -} #nextPlayers { display: flex; flex-direction: column; diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index 6f301b8..6a5ece1 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -1,23 +1,25 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} +import de.knockoutwhist.cards.Hand import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import exceptions.{CantPlayCardException, GameFullException, NotEnoughPlayersException, NotHostException, NotInThisGameException} import logic.PodManager +import logic.game.PollingEvents.CardPlayed +import logic.game.PollingEvents.GameStarted +import logic.game.{GameLobby, PollingEvents} import model.sessions.{PlayerSession, UserSession} +import model.users.User import play.api.* -import play.api.libs.json.Json +import play.api.libs.json.{JsArray, JsValue, Json} import play.api.mvc.* +import util.WebUIUtils import java.util.UUID import javax.inject.* +import scala.concurrent.Future import scala.util.Try - - -/** - * This controller creates an `Action` to handle HTTP requests to the - * application's home page. - */ +import scala.concurrent.ExecutionContext @Singleton class IngameController @Inject()( val controllerComponents: ControllerComponents, @@ -39,14 +41,12 @@ class IngameController @Inject()( case SelectTrump => Ok(views.html.ingame.selecttrump( g.getPlayerByUser(request.user), - g.logic, - gameId + g.logic )) case TieBreak => Ok(views.html.ingame.tie( g.getPlayerByUser(request.user), - g.logic, - gameId + g.logic )) case _ => InternalServerError(s"Invalid game state for in-game view. GameId: $gameId" + s" State: ${g.logic.getCurrentState}") @@ -67,70 +67,30 @@ class IngameController @Inject()( } } if (result.isSuccess) { - Ok(Json.obj( - "status" -> "success", - "redirectUrl" -> routes.IngameController.game(gameId).url - )) + Redirect(routes.IngameController.game(gameId)) } else { val throwable = result.failed.get throwable match { case _: NotInThisGameException => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + BadRequest(throwable.getMessage) case _: NotHostException => - Forbidden(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + Forbidden(throwable.getMessage) case _: NotEnoughPlayersException => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + BadRequest(throwable.getMessage) case _ => - InternalServerError(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + InternalServerError(throwable.getMessage) } } } - def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + def kickPlayer(gameId: String, playerToKick: UUID): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val game = podManager.getGame(gameId) - val playerToKickUUID = UUID.fromString(playerToKick) - val result = Try { - game.get.leaveGame(playerToKickUUID) - } - if(result.isSuccess) { - Ok(Json.obj( - "status" -> "success", - "redirectUrl" -> routes.IngameController.game(gameId).url - )) - } else { - InternalServerError(Json.obj( - "status" -> "failure", - "errorMessage" -> "Something went wrong." - )) - } + game.get.leaveGame(playerToKick) + Redirect(routes.IngameController.game(gameId)) } 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) - } - if (result.isSuccess) { - Ok(Json.obj( - "status" -> "success", - "redirectUrl" -> routes.MainMenuController.mainMenu().url - )) - } else { - InternalServerError(Json.obj( - "status" -> "failure", - "errorMessage" -> "Something went wrong." - )) - } + 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) @@ -162,10 +122,7 @@ class IngameController @Inject()( val game = podManager.getGame(gameId) game match { case Some(g) => - val jsonBody = request.body.asJson - val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue => - (jsValue \ "cardID").asOpt[String] - } + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) cardIdOpt match { case Some(cardId) => var optSession: Option[UserSession] = None @@ -177,51 +134,27 @@ class IngameController @Inject()( } optSession.foreach(_.lock.unlock()) if (result.isSuccess) { - Ok(Json.obj( - "status" -> "success", - "redirectUrl" -> routes.IngameController.game(gameId).url - )) + NoContent } else { val throwable = result.failed.get throwable match { case _: CantPlayCardException => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + BadRequest(throwable.getMessage) case _: NotInThisGameException => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + BadRequest(throwable.getMessage) case _: IllegalArgumentException => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + BadRequest(throwable.getMessage) case _: IllegalStateException => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + BadRequest(throwable.getMessage) case _ => - InternalServerError(Json.obj( - "status" -> "failure", - "errorMessage" -> throwable.getMessage - )) + InternalServerError(throwable.getMessage) } } case None => - BadRequest(Json.obj( - "status" -> "failure", - "errorMessage" -> "cardId Parameter is missing" - )) + BadRequest("cardId parameter is missing") } case None => - NotFound(Json.obj( - "status" -> "failure", - "errorMessage" -> "Game not found" - )) + NotFound("Game not found") } } } diff --git a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala index 2cd5f06..48c2f9f 100644 --- a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala +++ b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala @@ -19,7 +19,8 @@ class JavaScriptRoutingController @Inject()( routes.javascript.IngameController.startGame, routes.javascript.IngameController.kickPlayer, routes.javascript.IngameController.leaveGame, - routes.javascript.IngameController.playCard + routes.javascript.IngameController.playCard, + routes.javascript.IngameController.polling ) ).as("text/javascript") } diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 0e8ac9f..cd28c35 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -2,21 +2,23 @@ package logic.game import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.{Lobby, MainMenu} +import de.knockoutwhist.control.GameState.{InGame, Lobby, MainMenu} import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} -import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} +import de.knockoutwhist.events.global.{CardPlayedEvent, GameStateChangeEvent, SessionClosed} import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} import exceptions.* +import logic.game.PollingEvents.{CardPlayed, GameStarted} import model.sessions.{InteractionType, UserSession} import model.users.User import java.util.UUID import scala.collection.mutable import scala.collection.mutable.ListBuffer +import scala.concurrent.{Promise => ScalaPromise} class GameLobby private( val logic: GameLogic, @@ -29,7 +31,19 @@ class GameLobby private( logic.createSession() private val users: mutable.Map[UUID, UserSession] = mutable.Map() - + private var pollingState: mutable.Queue[PollingEvents] = mutable.Queue() + private val waitingPromises: mutable.Map[UUID, ScalaPromise[PollingEvents]] = mutable.Map() + + def registerWaiter(playerId: UUID): ScalaPromise[PollingEvents] = { + val promise = ScalaPromise[PollingEvents]() + waitingPromises.put(playerId, promise) + promise + } + + def removeWaiter(playerId: UUID): Unit = { + waitingPromises.remove(playerId) + } + def addUser(user: User): UserSession = { 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!") @@ -44,12 +58,29 @@ class GameLobby private( override def listen(event: SimpleEvent): Unit = { event match { + case event: CardPlayedEvent => + val newEvent = PollingEvents.CardPlayed + if (waitingPromises.nonEmpty) { + waitingPromises.values.foreach(_.success(newEvent)) + waitingPromises.clear() + } else { + pollingState.enqueue(newEvent) + } case event: PlayerEvent => users.get(event.playerId).foreach(session => session.updatePlayer(event)) case event: GameStateChangeEvent => if (event.oldState == MainMenu && event.newState == Lobby) { return } + if (event.oldState == Lobby && event.newState == InGame) { + val newEvent = PollingEvents.GameStarted + if (waitingPromises.nonEmpty) { + waitingPromises.values.foreach(_.success(newEvent)) + waitingPromises.clear() + } else { + pollingState.enqueue(newEvent) + } + } users.values.foreach(session => session.updatePlayer(event)) case event: SessionClosed => users.values.foreach(session => session.updatePlayer(event)) @@ -186,7 +217,9 @@ class GameLobby private( def getLogic: GameLogic = { logic } - + def getPollingState: mutable.Queue[PollingEvents] = { + pollingState + } private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { val playerOption = getMatch.totalplayers.find(_.id == userSession.id) if (playerOption.isEmpty) { diff --git a/knockoutwhistweb/app/logic/game/PollingEvents.scala b/knockoutwhistweb/app/logic/game/PollingEvents.scala new file mode 100644 index 0000000..933cf82 --- /dev/null +++ b/knockoutwhistweb/app/logic/game/PollingEvents.scala @@ -0,0 +1,6 @@ +package logic.game + +enum PollingEvents { + case CardPlayed + case GameStarted +} \ No newline at end of file diff --git a/knockoutwhistweb/app/views/ingame/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html index 271fc28..50f45f6 100644 --- a/knockoutwhistweb/app/views/ingame/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -10,18 +10,18 @@

Current Player

-

@gamelobby.getLogic.getCurrentPlayer.get.name

+

@gamelobby.getLogic.getCurrentPlayer.get.name

@if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) {

Next Player

@for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) { -

@nextplayer

+

@nextplayer

} }
-
+

Tricks Won

@@ -41,7 +41,7 @@ }
-
+
@for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) {
@@ -58,10 +58,10 @@

Trumpsuit

-

@gamelobby.getLogic.getCurrentRound.get.trumpSuit

+

@gamelobby.getLogic.getCurrentRound.get.trumpSuit

First Card
-
+
@if(gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) { @util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get) width="80px"/> } else { @@ -85,4 +85,9 @@
+ } diff --git a/knockoutwhistweb/app/views/lobby/lobby.scala.html b/knockoutwhistweb/app/views/lobby/lobby.scala.html index 75ba8d4..1ac678f 100644 --- a/knockoutwhistweb/app/views/lobby/lobby.scala.html +++ b/knockoutwhistweb/app/views/lobby/lobby.scala.html @@ -1,7 +1,7 @@ @(user: Option[model.users.User], gamelobby: logic.game.GameLobby) @main("Lobby") { -
+
@@ -66,4 +66,9 @@
+ } \ No newline at end of file diff --git a/knockoutwhistweb/app/views/main.scala.html b/knockoutwhistweb/app/views/main.scala.html index 50f3d41..e1cc5d1 100644 --- a/knockoutwhistweb/app/views/main.scala.html +++ b/knockoutwhistweb/app/views/main.scala.html @@ -22,7 +22,6 @@ @* And here's where we render the `Html` object containing * the page content. *@ @content - diff --git a/knockoutwhistweb/app/views/mainmenu/creategame.scala.html b/knockoutwhistweb/app/views/mainmenu/creategame.scala.html index 4daeb42..49d15f2 100644 --- a/knockoutwhistweb/app/views/mainmenu/creategame.scala.html +++ b/knockoutwhistweb/app/views/mainmenu/creategame.scala.html @@ -3,7 +3,7 @@ @main("Create Game") { @navbar(user)
-
+
diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 90a4542..c6ffc20 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -31,6 +31,7 @@ POST /game/:id/kickPlayer controllers.IngameController.kickPlayer(id: POST /game/:id/trump controllers.IngameController.playTrump(id: String) POST /game/:id/tie controllers.IngameController.playTie(id: String) -POST /game/:id/kickPlayer controllers.IngameController.kickPlayer(id: String, playerId: java.util.UUID) GET /game/:id/leaveGame controllers.IngameController.leaveGame(id: String) -POST /game/:id/playCard controllers.IngameController.playCard(id: String) \ No newline at end of file +POST /game/:id/playCard controllers.IngameController.playCard(id: String) +# Polling +GET /polling controllers.IngameController.polling(gameId: String) \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index aa8ec29..d48ba74 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -79,6 +79,157 @@ }) })() +function pollForUpdates(gameId) { + if (!gameId) { + console.error("Game ID is missing. Stopping poll."); + return; + } + const element = document.getElementById('card-slide'); + const element2 = document.getElementById('lobbybackground'); + // Safety check for the target element + if (!element && !element2) { + console.error("Polling target element not found. Stopping poll."); + // Use a timeout to retry in case the DOM loads late, passing gameId. + setTimeout(() => pollForUpdates(gameId), 5000); + return; + } + const route = jsRoutes.controllers.IngameController.polling(gameId); + + // Call your specific controller endpoint + fetch(route.url) + .then(response => { + if (response.status === 204) { + console.log("Polling: Timeout reached. Restarting poll."); + + // CRITICAL: Pass gameId in the recursive call + setTimeout(() => pollForUpdates(gameId), 5000); + } else if (response.ok && response.status === 200) { + response.json().then(data => { + + if (data.status === "cardPlayed" && data.handData) { + console.log("Event received: Card played. Redrawing hand."); + + const newHand = data.handData; + let newHandHTML = ''; + element.innerHTML = ''; + + newHand.forEach((cardId, index) => { + const cardHtml = ` +
+
+ + +
+
+ `; + newHandHTML += cardHtml; + }); + + element.innerHTML = newHandHTML; + + const currentPlayerElement = document.getElementById('current-player-name'); + if (currentPlayerElement) { + currentPlayerElement.textContent = data.currentPlayerName; + } + const nextPlayerElement = document.getElementById('next-player-name'); + if (nextPlayerElement && data.nextPlayer) { + // Use the correctly named field from the server response + nextPlayerElement.textContent = data.nextPlayer; + } else { + // Case 2: Player name is empty or null (signal to clear display). + nextPlayerElement.textContent = ""; + } + + const trumpElement = document.getElementById('trump-suit'); + if (trumpElement) { + trumpElement.textContent = data.trumpSuit; + } + const trickContainer = document.getElementById('trick-cards-container'); + if (trickContainer) { + let trickHTML = ''; + + // Iterate over the array of played cards received from the server + data.trickCards.forEach(trickCard => { + // Reconstruct the HTML structure from your template + trickHTML += ` +
+
+
+ +
+
+ ${trickCard.player} +
+
+
+ `; + }); + trickContainer.innerHTML = trickHTML; + } + const scoreBody = document.getElementById('score-table-body'); + if (scoreBody && data.scoreTable) { + let scoreHTML = ''; + scoreHTML += `

Tricks Won

+ +
+
PLAYER
+
TRICKS
+
` + data.scoreTable.forEach(score => { + scoreHTML += ` +
+
${score.name}
+
${score.tricks}
+
+ `; + }); + scoreBody.innerHTML = scoreHTML; + } + const firstCardContainer = document.getElementById('first-card-container'); + const cardId = data.firstCardId; // This will be "KH", "S7", or "BLANK" + + if (firstCardContainer) { + let imageSrc = ''; + let altText = 'First Card'; + + // Check if a card was actually played or if it's the start of a trick + if (cardId === "BLANK") { + imageSrc = "/assets/images/cards/1B.png"; + altText = "Blank Card"; + } else { + imageSrc = `/assets/images/cards/${cardId}.png`; + } + + // Reconstruct the image HTML (assuming the inner element needs replacement) + const newImageHTML = ` + ${altText} + `; + + // Clear the container and insert the new image + firstCardContainer.innerHTML = newImageHTML; + } + } else if (data.status === "gameStart") { + window.location.href = data.redirectUrl; + } + pollForUpdates(gameId); + }); + } else { + // Handle network or server errors + console.error(`Polling error: Status ${response.status}`); + // Wait before retrying, passing gameId correctly + setTimeout(() => pollForUpdates(gameId), 5000); + } + }) + .catch(error => { + console.error("Network error during polling:", error); + // Wait before retrying on network failure, passing gameId correctly + setTimeout(() => pollForUpdates(gameId), 5000); + }); +} + function createGameJS() { let lobbyName = document.getElementById("lobbyname").value; if (lobbyName === "") { @@ -226,10 +377,26 @@ function handlePlayCard(cardobject, gameId) { const jsonObj = { cardID: cardId } - sendPlayCardRequest(jsonObj, gameId) + sendPlayCardRequest(jsonObj, gameId, cardobject) } -function sendPlayCardRequest(jsonObj, gameId) { +function sendPlayCardRequest(jsonObj, gameId, cardobject) { + const wiggleKeyframes = [ + { transform: 'translateX(0)' }, + { transform: 'translateX(-5px)' }, + { transform: 'translateX(5px)' }, + { transform: 'translateX(-5px)' }, + { transform: 'translateX(0)' } + ]; + + // Define the timing options + const wiggleTiming = { + duration: 400, // 0.4 seconds + iterations: 1, + easing: 'ease-in-out', + // Fill mode ensures the final state is applied until reset + fill: 'forwards' + }; const route = jsRoutes.controllers.IngameController.playCard(gameId); fetch(route.url, { @@ -249,11 +416,13 @@ function sendPlayCardRequest(jsonObj, gameId) { }) .then(data => { if (data.status === 'success') { - window.location.href = data.redirectUrl; + //window.location.href = data.redirectUrl; } }) .catch(error => { - if (error && error.errorMessage) { + if (error && error.errorMessage.includes("You can't play this card!")) { + cardobject.parentElement.animate(wiggleKeyframes, wiggleTiming); + } else if (error && error.errorMessage) { alert(`${error.errorMessage}`); } else { alert('An unexpected error occurred. Please try again.');