feat(ui): Implement countless feature using the SJWP (#89)

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #89
This commit is contained in:
2025-11-27 08:53:37 +01:00
parent 2aee79bb68
commit 1f96290371
14 changed files with 289 additions and 48 deletions

View File

@@ -4,7 +4,7 @@ import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent,
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User
import play.api.libs.json.JsObject
import play.api.libs.json.{JsObject, JsValue}
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
@@ -39,14 +39,38 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
def handleWebResponse(eventType: String, data: JsObject): Unit = {
lock.lock()
Try {
val result = Try {
eventType match {
case "Ping" =>
// No action needed for Ping
()
case "Start Game" =>
gameLobby.startGame(user)
case "play Card" =>
val maybeCardIndex: Option[Int] = (data \ "cardindex").asOpt[Int]
maybeCardIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.playCard(session, index)
case None =>
println("Card Index not found or is not a number.")
}
case "Picked Trumpsuit" =>
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.")
}
}
}
lock.unlock()
if (result.isFailure) {
val throwable = result.failed.get
throw throwable
}
}
}

View File

@@ -1,5 +1,6 @@
package model.users
import java.util.UUID
case class User(
@@ -16,5 +17,4 @@ case class User(
private def withPasswordHash(newPasswordHash: String): User = {
this.copy(passwordHash = newPasswordHash)
}
}
}

View File

@@ -1,8 +1,8 @@
package util
import de.knockoutwhist.cards.{Card, Hand}
import de.knockoutwhist.cards.CardValue.*
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 scalafx.scene.image.Image

View File

@@ -1,12 +1,11 @@
package util
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
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.{CardPlayedEventMapper, GameStateEventMapper, KickEventMapper, LeftEventMapper, LobbyUpdateEventMapper, ReceivedHandEventMapper, SessionClosedMapper, SimpleEventMapper, TurnEventMapper}
import util.mapper.*
object WebsocketEventMapper {
@@ -27,6 +26,10 @@ object WebsocketEventMapper {
registerCustomMapper(ReceivedHandEventMapper)
registerCustomMapper(GameStateEventMapper)
registerCustomMapper(CardPlayedEventMapper)
registerCustomMapper(NewRoundEventMapper)
registerCustomMapper(NewTrickEventMapper)
registerCustomMapper(TrickEndEventMapper)
registerCustomMapper(RequestCardEventMapper)
registerCustomMapper(LobbyUpdateEventMapper)
registerCustomMapper(LeftEventMapper)
registerCustomMapper(KickEventMapper)

View File

@@ -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)
)
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}

View File

@@ -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"))
)
}
}

View File

@@ -107,7 +107,7 @@
} else {
@for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto handcard" style="border-radius: 6px">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-card-id="@i" style="border-radius: 6px" onclick="handlePlayCard(this, '@gamelobby.id', '@player.isInDogLife')">
<div class="btn btn-outline-light p-0 border-0 shadow-none" id="@i" data-card-id="@i" style="border-radius: 6px" onclick="handlePlayCard(@i, '@player.isInDogLife')">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</div>
@if(player.isInDogLife) {

View File

@@ -18,25 +18,25 @@
<div class="row justify-content-center col-auto mb-5">
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="0" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
<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, '@gamelobby.id')">
<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, '@gamelobby.id')">
<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, '@gamelobby.id')">
<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>

View File

@@ -69,7 +69,7 @@
}
</div>
<div class="col-12 text-center mb-5">
<div class="btn btn-success" onclick="startGame('@gamelobby.id')">Start Game</div>
<div class="btn btn-success" onclick="startGame()">Start Game</div>
</div>
} else {
<div id="players" class="justify-content-center align-items-center d-flex">
@@ -98,6 +98,7 @@
</div>
</main>
<script>
/*
function waitForFunction(name, checkInterval = 100) {
return new Promise(resolve => {
const timer = setInterval(() => {
@@ -109,5 +110,6 @@
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
*/
connectWebSocket()
</script>

View File

@@ -30,4 +30,4 @@
</main>
<script>
disconnectWebSocket();
</script>
</script>

View File

@@ -1,3 +1,30 @@
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;
@@ -38,6 +65,100 @@ function receiveHandEvent(eventData) {
}
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.name) || 0;
const countB = playercounts.get(playerB.name) || 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>
`
});
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');
}
function receiveGameStateChange(eventData) {
const content = eventData.content;
@@ -85,37 +206,6 @@ function receiveCardPlayedEvent(eventData) {
`;
firstCardContainer.html(newFirstCardHTML);
}
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-section');
let currentPlayerName = currentPlayer.name;
if (currentPlayer.dog) {
currentPlayerName += " 🐶";
}
currentPlayerNameContainer.text(currentPlayerName);
if (nextPlayers.length === 0) {
nextPlayerText.hide();
nextPlayersContainer.html('');
} else {
nextPlayerText.show();
let nextPlayersHtml = '';
nextPlayers.forEach((player) => {
let playerName = player.name;
if (player.dog) {
playerName += " 🐶";
}
nextPlayersHtml += `<p className="fs-5 text-primary">${playerName}</p>`;
});
nextPlayersContainer.html(nextPlayersHtml);
}
}
function receiveLobbyUpdateEvent(eventData) {
const host = eventData.host;
const maxPlayers = eventData.maxPlayers;
@@ -218,9 +308,15 @@ function receiveTurnEvent(eventData) {
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("TurnEvent", receiveTurnEvent)
globalThis.alertMessage = alertMessage

View File

@@ -1,10 +1,58 @@
function handlePlayCard(card, dog) {
// TODO needs implementation
function handlePlayCard(cardIndex, dog) {
const cardslide = $('#card-slide')
cardslide.addClass("inactive")
const payload = {
cardindex: cardIndex,
isDog: dog
}
sendEventAndWait("play Card", payload).then(
() => {
console.debug("play card successful")
const datacardid = $(`#${cardIndex}`)
datacardid.parent('.handcard').remove();
cardslide.find('.handcard').each(function(newIndex) {
const $innerButton = $(this).find('.btn');
$innerButton.attr('id', newIndex);
$innerButton.attr('data-card-id', newIndex);
const isInDogLife = $innerButton.attr('onclick').includes("'true'") ? 'true' : 'false';
$innerButton.attr('onclick', `handlePlayCard(${newIndex}, '${isInDogLife}')`);
console.debug(`Re-indexed card: Old index was ${$innerButton.attr('data-card-id')}, New index is ${newIndex}`);
});
}
).catch(
(err) => {
const cardslide = $('#card-slide')
console.warn("play card was not successful")
if (err.message === "You can't play this card!") {
cardslide.removeClass("inactive")
}
alertMessage("You aren't allowed to play this card")
}
)
}
function handleSkipDogLife(button) {
// TODO needs implementation
}
function startGame() {
sendEvent("Start Game")
}
function handleTrumpSelection(object) {
const $button = $(object);
const trumpIndex = parseInt($button.data('trump'));
const payload = {
suitIndex: trumpIndex
}
console.log("SENDING TRUMP SUIT SELECTION:", payload);
sendEvent("Picked Trumpsuit", payload)
}
function handleKickPlayer(playerId) {
// TODO needs implementation
}