From 7bbe33725f45a55d8009964e2f50c27f50a21cdc Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 28 Mar 2025 00:00:51 +0100 Subject: [PATCH 1/6] fix(backend): remove card from player before calling PlayCard --- backend/api/websocket.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/api/websocket.go b/backend/api/websocket.go index 5a75abd..55ceee1 100644 --- a/backend/api/websocket.go +++ b/backend/api/websocket.go @@ -248,7 +248,7 @@ func onPlayerJoin(client *socketio.Socket, room *types.Room, player *types.Playe return } card := player.Cards[*updatePlayerRequest.CardIndex] - if !room.CardDeck.PlayCard(card) { + if !room.CardDeck.CanPlay(card) { client.Emit("Status", types.S2C_Status{ IsError: true, StatusCode: "card_not_playable", @@ -257,6 +257,9 @@ func onPlayerJoin(client *socketio.Socket, room *types.Room, player *types.Playe return } player.Cards = append(player.Cards[:*updatePlayerRequest.CardIndex], player.Cards[*updatePlayerRequest.CardIndex+1:]...) + if !room.CardDeck.PlayCard(card) { + slog.Error("Cannot play card after checking", "roomId", room.RoomId.Hex(), "playerId", player.PlayerId.Hex()) + } game.OnPlayCard(room, player, *updatePlayerRequest.CardIndex, card) if len(player.Cards) == 0 { From 4104b01978e2ece1a5b3039867940fb6533aab6c Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 28 Mar 2025 00:03:18 +0100 Subject: [PATCH 2/6] feat(backend): implement changing card deck --- backend/api/websocket.go | 30 ++++++++++++++++++++++++++++++ backend/game/game.go | 20 +++++++++++++++++++- backend/types/websocket_packets.go | 3 +++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/backend/api/websocket.go b/backend/api/websocket.go index 55ceee1..c18c2de 100644 --- a/backend/api/websocket.go +++ b/backend/api/websocket.go @@ -95,6 +95,36 @@ func onPlayerJoin(client *socketio.Socket, room *types.Room, player *types.Playe game.OnRoomUpdate(room) }) + client.On("SetCardDeck", func(datas ...any) { + setCardDeckRequest := types.C2S_SetCardDeck{} + unpackData(datas, &setCardDeckRequest) + + if room.GameState != types.StateLobby { + client.Emit("Status", types.S2C_Status{ + IsError: true, + StatusCode: "game_already_running", + Message: "You can't change the card deck while the game is running", + }) + return + } + if !player.HasPermissionBit(types.PermissionHost) { + client.Emit("Status", types.S2C_Status{ + IsError: true, + StatusCode: "insufficient_permission", + Message: "You can't change the card deck unless you are host", + }) + return + } + if !game.SetCardDeck(room, setCardDeckRequest.CardDeckId) { + client.Emit("Status", types.S2C_Status{ + IsError: true, + StatusCode: "invalid_card_deck", + Message: "No card deck exists with this ID", + }) + return + } + }) + client.On("UpdatePlayer", func(datas ...any) { player.Mutex.Lock() defer player.Mutex.Unlock() diff --git a/backend/game/game.go b/backend/game/game.go index 9792ea0..2ee1613 100644 --- a/backend/game/game.go +++ b/backend/game/game.go @@ -123,6 +123,24 @@ func UpdateGameState(room *types.Room, newState types.GameState) { OnRoomUpdate(room) } +func SetCardDeck(room *types.Room, id int) bool { + if id < 0 || id > 1 { + return false + } + room.CardDeckId = id + OnRoomUpdate(room) + return true +} + +func CreateCardDeckObj(room *types.Room) { + switch room.CardDeckId { + case 0: + room.CardDeck = &decks.Classic{} + case 1: + room.CardDeck = &decks.HexV1{} + } +} + func BroadcastInRoom(room *types.Room, topic string, data interface{}) { for _, player := range room.Players { if !player.Connection.IsConnected || player.Connection.Socket == nil { @@ -179,7 +197,7 @@ func StartGame(room *types.Room) { if room.GameState != types.StateLobby { return } - room.CardDeck = &decks.Classic{} + CreateCardDeckObj(room) room.CardDeck.Init(room) UpdateGameState(room, types.StateRunning) UpdateAllPlayers(room) diff --git a/backend/types/websocket_packets.go b/backend/types/websocket_packets.go index 77b649f..16f917e 100644 --- a/backend/types/websocket_packets.go +++ b/backend/types/websocket_packets.go @@ -47,6 +47,9 @@ type S2C_PlayedCardUpdate struct { Card Card } +type C2S_SetCardDeck struct { + CardDeckId int +} type C2S_UpdatePlayer struct { PlayerId bson.ObjectID Username *string From 2cfcd0089fbb44394a2ee51282e56073baee2b8d Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 28 Mar 2025 00:05:10 +0100 Subject: [PATCH 3/6] feat(deck-hexv1): implement experimental HexV1 card deck --- backend/decks/decks.go | 8 ++ backend/decks/hexv1.go | 205 +++++++++++++++++++++++++++++++++++++++++ backend/game/game.go | 2 +- backend/utils/utils.go | 3 + 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 backend/decks/hexv1.go diff --git a/backend/decks/decks.go b/backend/decks/decks.go index 8b61151..0cb27f8 100644 --- a/backend/decks/decks.go +++ b/backend/decks/decks.go @@ -13,6 +13,10 @@ func DeckFromInterface(cardDeckId int, cardDeck bson.D) types.CardDeck { deck := Classic{} bson.Unmarshal(bsonBytes, &deck) return &deck + case 1: + deck := HexV1{} + bson.Unmarshal(bsonBytes, &deck) + return &deck } return nil @@ -26,6 +30,10 @@ func CardFromInterface(cardDeckId int, card bson.D) types.Card { deck := ClassicCard{} bson.Unmarshal(bsonBytes, &deck) return &deck + case 1: + deck := HexV1Card{} + bson.Unmarshal(bsonBytes, &deck) + return &deck } return nil } diff --git a/backend/decks/hexv1.go b/backend/decks/hexv1.go new file mode 100644 index 0000000..fab58ac --- /dev/null +++ b/backend/decks/hexv1.go @@ -0,0 +1,205 @@ +package decks + +import ( + "fmt" + "math/rand/v2" + + "github.com/HexCardGames/HexDeck/types" + "github.com/HexCardGames/HexDeck/utils" +) + +type HexV1 struct { + room *types.Room + CardsPlayed []*HexV1Card + PlayerOrder []int + ActiveIndex int +} + +type HexV1Card struct { + Symbol string + Color string + NumericValue int +} + +var HexV1Colors = []string{"blue", "green", "yellow", "purple"} +var HexV1ActionCards = []string{"shuffle", "skip", "draw", "swap"} + +func (deck *HexV1) Init(room *types.Room) { + deck.room = room + deck.PlayerOrder = make([]int, len(room.Players)) + deck.ActiveIndex = 0 + + deck.room.PlayersMutex.Lock() + defer deck.room.PlayersMutex.Unlock() + for i, player := range deck.room.Players { + deck.PlayerOrder[i] = i + player.Mutex.Lock() + defer player.Mutex.Unlock() + deck.drawMany(player, 8) + } +} + +func (deck *HexV1) SetRoom(room *types.Room) { + deck.room = room +} + +func (deck *HexV1) IsEmpty() bool { + return false +} + +func (deck *HexV1) getTopCard() *HexV1Card { + if len(deck.CardsPlayed) == 0 { + return nil + } + return deck.CardsPlayed[len(deck.CardsPlayed)-1] +} + +func (deck *HexV1) GetTopCard() types.Card { + return deck.getTopCard() +} + +func (deck *HexV1) generateCard() *HexV1Card { + cardType := rand.IntN(16 + len(HexV1ActionCards)) + cardColor := HexV1Colors[rand.IntN(len(HexV1Colors))] + if cardType < 16 { + return &HexV1Card{ + Symbol: fmt.Sprintf("%x", cardType), + Color: cardColor, + NumericValue: cardType, + } + } + cardSymbol := HexV1ActionCards[cardType-16] + if rand.IntN(100) <= 10 { + cardColor = "rainbow" + } + return &HexV1Card{ + Symbol: "action:" + cardSymbol, + Color: cardColor, + NumericValue: 3, + } +} + +func (deck *HexV1) drawCard(player *types.Player) types.Card { + card := deck.generateCard() + player.Cards = append(player.Cards, card) + return card +} + +func (deck *HexV1) drawMany(player *types.Player, cards int) { + for i := 0; i < cards; i++ { + deck.drawCard(player) + } +} + +func (deck *HexV1) DrawCard() types.Card { + // Can't draw another card before wildcard color is selected + topCard := deck.getTopCard() + if topCard != nil && topCard.Color == "rainbow" { + return nil + } + + card := deck.drawCard(deck.getPlayer(deck.ActiveIndex)) + deck.nextPlayer() + return card +} + +func (deck *HexV1) getNextValidIndex(index int) int { + if len(deck.room.Players) == 0 || len(deck.PlayerOrder) == 0 { + return -1 + } + checkIndex := utils.Mod(index, len(deck.PlayerOrder)) + for deck.PlayerOrder[checkIndex] >= len(deck.room.Players) { + checkIndex = utils.Mod(checkIndex+1, len(deck.PlayerOrder)) + } + return checkIndex +} + +func (deck *HexV1) getPlayer(index int) *types.Player { + playerIndex := deck.getNextValidIndex(index) + if playerIndex == -1 { + return nil + } + return deck.room.Players[deck.PlayerOrder[playerIndex]] +} + +func (deck *HexV1) getNextPlayerIndex() int { + return deck.getNextValidIndex(deck.ActiveIndex + 1) +} + +func (deck *HexV1) nextPlayer() { + deck.ActiveIndex = deck.getNextPlayerIndex() +} + +func (deck *HexV1) IsPlayerActive(target *types.Player) bool { + return deck.getPlayer(deck.ActiveIndex) == target +} + +func (deck *HexV1) CanPlay(card types.Card) bool { + topCard := deck.getTopCard() + checkCard := card.(*HexV1Card) + if topCard == nil || checkCard == nil { + return topCard == nil + } + return topCard.Color != "rainbow" && (checkCard.Color == "rainbow" || checkCard.Color == topCard.Color || checkCard.Symbol == topCard.Symbol) +} + +func (deck *HexV1) PlayCard(card types.Card) bool { + if !deck.CanPlay(card) { + return false + } + deckCard := card.(*HexV1Card) + targetPlayer := deck.getPlayer(deck.ActiveIndex) + nextPlayer := deck.getPlayer(deck.getNextPlayerIndex()) + + if deckCard.Symbol == "action:skip" && deckCard.Color != "rainbow" { + deck.nextPlayer() + } else if deckCard.Symbol == "action:draw" { + amount := 3 + topCard := deck.getTopCard() + if topCard != nil { + amount = topCard.NumericValue + } + deck.drawMany(nextPlayer, amount) + } else if deckCard.Symbol == "action:shuffle" { + utils.ShuffleSlice(&deck.PlayerOrder) + } else if deckCard.Symbol == "action:swap" { + p1Cards := targetPlayer.Cards + p2Cards := nextPlayer.Cards + targetPlayer.Cards = p2Cards + nextPlayer.Cards = p1Cards + } + + if deckCard.Color != "rainbow" { + deck.nextPlayer() + } + + deck.CardsPlayed = append(deck.CardsPlayed, deckCard) + return true +} + +func (deck *HexV1) UpdatePlayedCard(cardData interface{}) types.Card { + topCard := deck.getTopCard() + if topCard.Color != "rainbow" { + return nil + } + updateData, ok := cardData.(map[string]interface{}) + if !ok { + return nil + } + newColor, ok := updateData["Color"].(string) + if !ok { + return nil + } + + for _, color := range HexV1Colors { + if newColor == color { + deck.nextPlayer() + if topCard.Symbol == "action:skip" { + deck.nextPlayer() + } + topCard.Color = color + return topCard + } + } + return nil +} diff --git a/backend/game/game.go b/backend/game/game.go index 2ee1613..f72ae18 100644 --- a/backend/game/game.go +++ b/backend/game/game.go @@ -39,7 +39,7 @@ func CreateRoom() *types.Room { GameState: types.StateLobby, Players: make([]*types.Player, 0), PlayersMutex: &sync.Mutex{}, - CardDeckId: 0, + CardDeckId: 1, } db.Conn.InsertRoom(newRoom) diff --git a/backend/utils/utils.go b/backend/utils/utils.go index 44a5044..8a2c6da 100644 --- a/backend/utils/utils.go +++ b/backend/utils/utils.go @@ -34,5 +34,8 @@ func ShuffleSlice[T any](slice *([]T)) { } func Mod(a, b int) int { + if b == 0 { + return 0 + } return (a%b + b) % b } From 338710d76227dc0b37d44aed514bae0a94210ced Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 28 Mar 2025 00:25:30 +0100 Subject: [PATCH 4/6] feat(deck-hexv1): add HexV1Card component --- .../src/components/Cards/HexV1Card.svelte | 110 ++++++++++++++++++ frontend/src/views/Game/Main.svelte | 3 +- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Cards/HexV1Card.svelte diff --git a/frontend/src/components/Cards/HexV1Card.svelte b/frontend/src/components/Cards/HexV1Card.svelte new file mode 100644 index 0000000..a23e034 --- /dev/null +++ b/frontend/src/components/Cards/HexV1Card.svelte @@ -0,0 +1,110 @@ + + +
+ {#if data.Symbol.length <= 2} + {data.Symbol} + {:else} + {data.Symbol.split(":")[1].replace("_", " ")} + {/if} + {#if data.Color == "rainbow" && canUpdateCard} +
+
+ + +
+
+ + +
+
+ {/if} +
+ + diff --git a/frontend/src/views/Game/Main.svelte b/frontend/src/views/Game/Main.svelte index 3f179d7..828279a 100644 --- a/frontend/src/views/Game/Main.svelte +++ b/frontend/src/views/Game/Main.svelte @@ -5,12 +5,13 @@ import CardDisplay from "../../components/Game/CardDisplay.svelte"; import { sessionStore } from "../../stores/sessionStore"; import OpponentDisplay from "../../components/Game/OpponentDisplay.svelte"; + import HexV1Card from "../../components/Cards/HexV1Card.svelte"; let maxRotationDeg = 20; let centerDistancePx = 200; let cardWidth = 100; let cardHeight = 150; - let cardComponent = ClassicCard; + let cardComponent = [ClassicCard, HexV1Card][$sessionStore.cardDeckId ?? 0]; let opponents = derived(sessionStore.store, ($store) => $store.players.filter((e) => e.PlayerId != sessionStore.getUserId())); let playerActive = derived(sessionStore.store, ($store) => ($store.playerStates[$store.userId ?? ""] ?? "").Active ?? false); From 6349d5055fc9ca7ed4c96603bb327dfa2df03421 Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 28 Mar 2025 00:30:25 +0100 Subject: [PATCH 5/6] fix(frontend): fix incorrect parameter in german game_status string --- frontend/src/i18n/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index ed3d1f2..f8a9941 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -83,7 +83,7 @@ "disconnected": "Getrennt" }, "game_status": { - "game_status": "Spielstatus: {game_status}", + "game_status": "Spielstatus:", "lobby": "Lobby", "running": "Läuft", "ended": "Beendet" From 0d3b42193fb60f0f9bdbefbe7e2d4ea2ccc34d7f Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 28 Mar 2025 00:32:03 +0100 Subject: [PATCH 6/6] feat(frontend): implement changing the active card deck --- .../src/components/CardDeckShowcase.svelte | 29 ++++++ frontend/src/i18n/de.json | 8 +- frontend/src/i18n/en.json | 6 +- frontend/src/routes/Game.svelte | 11 ++- frontend/src/routes/_module.svelte | 2 +- frontend/src/stores/cardDeck.ts | 13 +++ frontend/src/stores/sessionStore.ts | 11 +++ frontend/src/views/Game/Lobby.svelte | 98 +++++++++++++++---- frontend/src/views/Game/Main.svelte | 13 ++- 9 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/CardDeckShowcase.svelte create mode 100644 frontend/src/stores/cardDeck.ts diff --git a/frontend/src/components/CardDeckShowcase.svelte b/frontend/src/components/CardDeckShowcase.svelte new file mode 100644 index 0000000..f2197a8 --- /dev/null +++ b/frontend/src/components/CardDeckShowcase.svelte @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index f8a9941..32e884f 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -69,7 +69,13 @@ "player_name": "Spielername", "status": "Status", "host": "Host", - "you": "Du" + "you": "Du", + "player": "Spieler", + "return_to_game": "Zurück zum Spiel", + "selected_card_deck": "Ausgewähltes Kartendeck:", + "change_card_deck": "Ändern", + "choose_card_deck": "Auswählen", + "card_deck_modal": "Wähle ein Kartendeck aus" }, "end_screen": { "game_has_ended": "Das Spiel ist vorbei", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 8b896d7..1b7b5c3 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -71,7 +71,11 @@ "host": "Host", "you": "You", "player": "Player", - "return_to_game": "Return to game" + "return_to_game": "Return to game", + "selected_card_deck": "Selected card deck:", + "change_card_deck": "Change", + "choose_card_deck": "Choose", + "card_deck_modal": "Select a card deck" }, "end_screen": { "game_has_ended": "The game has ended", diff --git a/frontend/src/routes/Game.svelte b/frontend/src/routes/Game.svelte index 5a18df4..aac716a 100644 --- a/frontend/src/routes/Game.svelte +++ b/frontend/src/routes/Game.svelte @@ -10,6 +10,11 @@ import { requestJoinRoom } from "../stores/roomStore"; import gameStore from "../stores/gameStore"; + let maxRotationDeg = 20; + let centerDistancePx = 200; + let cardWidth = 100; + let cardHeight = 150; + onMount(async () => { // TODO: check if already connected to room, currently its overwriting the session const params = new URLSearchParams(window.location.search); @@ -51,7 +56,7 @@ {#if $sessionStore.gameState == GameState.Lobby}
- +
{/if} @@ -59,11 +64,11 @@
{#if $gameStore.isLobbyOverlayShown}
- +
{/if} -
+
{:else if $sessionStore.gameState == GameState.Ended} diff --git a/frontend/src/routes/_module.svelte b/frontend/src/routes/_module.svelte index 1f27cd9..f46c237 100644 --- a/frontend/src/routes/_module.svelte +++ b/frontend/src/routes/_module.svelte @@ -65,7 +65,7 @@
-
+
diff --git a/frontend/src/stores/cardDeck.ts b/frontend/src/stores/cardDeck.ts new file mode 100644 index 0000000..659451d --- /dev/null +++ b/frontend/src/stores/cardDeck.ts @@ -0,0 +1,13 @@ +import ClassicCard from "../components/Cards/ClassicCard.svelte"; +import HexV1Card from "../components/Cards/HexV1Card.svelte"; + +interface CardDeck { + id: number; + name: string; + cardComponent: any; +} + +export const CardDecks: CardDeck[] = [ + { id: 0, name: "Classic", cardComponent: ClassicCard }, + { id: 1, name: "HexV1", cardComponent: HexV1Card }, +]; diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts index 7c1a12e..809be5d 100644 --- a/frontend/src/stores/sessionStore.ts +++ b/frontend/src/stores/sessionStore.ts @@ -80,6 +80,10 @@ interface PlayedCardUpdateObj { Card: Card; } +interface SetCardDeckReq { + CardDeckId: number; +} + interface PlayCardReq { CardIndex?: number; CardData: any; @@ -367,6 +371,13 @@ class SessionManager { } } + setCardDeck(id: number) { + let request: SetCardDeckReq = { + CardDeckId: id, + }; + this.sendMessage("SetCardDeck", JSON.stringify(request)); + } + drawCard() { this.sendMessage("DrawCard", ""); } diff --git a/frontend/src/views/Game/Lobby.svelte b/frontend/src/views/Game/Lobby.svelte index 6d40839..cc00282 100644 --- a/frontend/src/views/Game/Lobby.svelte +++ b/frontend/src/views/Game/Lobby.svelte @@ -1,10 +1,12 @@