Compare commits

36 Commits

Author SHA1 Message Date
bdc8a151f4 Merge branch 'feature/hexv1-card-deck' into dev 2025-07-06 20:23:59 +02:00
0d3b42193f feat(frontend): implement changing the active card deck 2025-03-28 00:32:03 +01:00
6349d5055f fix(frontend): fix incorrect parameter in german game_status string 2025-03-28 00:30:25 +01:00
338710d762 feat(deck-hexv1): add HexV1Card component 2025-03-28 00:25:30 +01:00
2cfcd0089f feat(deck-hexv1): implement experimental HexV1 card deck 2025-03-28 00:05:10 +01:00
4104b01978 feat(backend): implement changing card deck 2025-03-28 00:03:18 +01:00
7bbe33725f fix(backend): remove card from player before calling PlayCard 2025-03-28 00:00:51 +01:00
37c47a7b72 fix(frontend): adjust breakpoint of lobby grid 2025-03-27 22:35:55 +01:00
c8b9a32f25 fix(frontend): do not leave room when receiving an error from the backend 2025-03-27 22:33:43 +01:00
3539cb6922 fix(frontend): use correct flex direction for displaying multiple opponents 2025-03-27 22:29:36 +01:00
5e8890d823 fix(deck-classic): automatically refill card deck 2025-03-21 12:59:30 +01:00
c2e895d94a feat(frontend): add basic end screen 2025-03-17 13:32:55 +01:00
0b41a5e795 fix(deck-classic): prevent placing a card on top of a black card 2025-03-17 10:54:38 +01:00
a54eb51e9a fix(frontend): make sure player is active before updating card 2025-03-11 19:26:47 +01:00
4b13b9fc95 fix: make sure player is active before drawing a card 2025-03-11 19:24:10 +01:00
5c83cd6ce2 Merge branch 'dev' into feature/game-view 2025-03-11 18:54:03 +01:00
134b89118f ops: add docker support 2025-03-11 18:53:26 +01:00
bbf83ef811 feat(backend): serve static frontend files 2025-03-11 18:50:10 +01:00
9088916f92 fix(backend): make sure database connection was successful before continuing 2025-03-11 17:50:25 +01:00
ee98d92f52 feat(frontend): implement basic card rendering, drawing and playing 2025-03-11 17:09:20 +01:00
1597fb9b31 feat(deck-classic): draw 7 cards per player on game start 2025-03-11 16:19:47 +01:00
14aeb21772 feat(backend): send own cards and player states on room join 2025-03-11 12:39:22 +01:00
ba44508f00 style(backend): fix double import of socketio library 2025-03-11 12:37:32 +01:00
889ee4ce4f fix(backend): check that player socket exists before trying to send data 2025-03-11 12:35:38 +01:00
49e84eaac7 fix(backend): properly disconnect player after getting kicked 2025-03-11 12:33:41 +01:00
a4a26f04d4 fix(backend): fix crash when trying to update player data concurrently 2025-03-11 12:32:36 +01:00
d4f194b146 build(frontend): load backend url for vite dev proxy from env 2025-03-07 16:31:41 +01:00
5f1404c7d7 style: add svelte support to prettier and format svelte components 2025-03-07 11:17:12 +01:00
540c70216c style: add prettier configuration and format code 2025-03-07 10:33:46 +01:00
4f96206b8a fix: update initial gameState to -1 in sessionStore 2025-03-07 09:38:57 +01:00
8fe9519afc feat: add game store for lobby overlay/player management 2025-03-07 09:38:35 +01:00
e5f8876464 chore: upgrade frontend dependencies
- Upgraded all frontend dependencies to their latest versions
- Updated Tailwind CSS to v4 and adjusted configuration

Note: This is WIP. Some features are broken and there are known bugs that need to be addressed.
2025-03-06 12:14:13 +01:00
76e657b5ec Merge branch 'dev' of https://github.com/HexCardGames/HexDeck into dev 2025-03-06 10:25:16 +01:00
e8e36e6674 feat: implement room joining by link and add dedicated room-utils store 2025-03-06 10:24:44 +01:00
c4b9f9287f 🎉 🚧 Start working on backend 2025-03-06 10:14:21 +01:00
3217b2c4c3 🎉first frontend commit (WIP) 2025-03-04 09:43:45 +01:00
71 changed files with 13382 additions and 0 deletions

3
.env.sample Normal file
View File

@ -0,0 +1,3 @@
LISTEN_HOST=0.0.0.0
LISTEN_PORT=3000
MONGO_URI="mongodb://127.0.0.1:27017/"

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# IDEs and editors
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
# Secrets
.env
# MongoDB docker volume
data/

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:23-alpine AS frontend
WORKDIR /frontend
COPY frontend/package*.json /frontend
RUN npm ci
RUN npm i @tailwindcss/oxide-linux-x64-musl
COPY frontend/ /frontend
RUN npm run build
FROM golang:1.24-alpine AS backend
WORKDIR /backend
COPY backend/go.* /backend/
RUN go mod download
COPY backend/ /backend
COPY --from=frontend /frontend/dist/client/ /backend/public/
RUN go build -o HexDeck
FROM scratch
WORKDIR /app
COPY --from=backend /backend/HexDeck .
CMD ["/app/HexDeck"]

1
backend/.dockerignore Normal file
View File

@ -0,0 +1 @@
HexDeck

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
HexDeck

138
backend/api/api.go Normal file
View File

@ -0,0 +1,138 @@
package api
import (
"log/slog"
"net/http"
"github.com/HexCardGames/HexDeck/db"
"github.com/HexCardGames/HexDeck/game"
"github.com/HexCardGames/HexDeck/types"
"github.com/gin-gonic/gin"
)
type ErrorReply struct {
StatusCode string
Message string
}
type StatsReply struct {
TotalGamesPlayed int
RunningGames int
OnlinePlayerCount int
}
type ImprintReply struct {
Content string
}
type CreateRoomReply struct {
JoinCode string
}
type JoinRoomRequest struct {
JoinCode string
Username string
}
type LeaveRoomRequest struct {
SessionToken string
}
func RegisterApi(server *gin.Engine) {
server.GET("/api/stats", func(c *gin.Context) {
stats := game.CalculateStats()
c.JSON(http.StatusOK, StatsReply{
TotalGamesPlayed: db.Conn.QueryGlobalStats().GamesPlayed,
RunningGames: stats.RunningGames,
OnlinePlayerCount: stats.OnlinePlayerCount,
})
})
server.GET("/api/imprint", func(c *gin.Context) {
// TODO: Implement imprint endpoint
c.JSON(http.StatusOK, ImprintReply{
Content: "Not implemented yet",
})
})
server.POST("/api/room/create", func(c *gin.Context) {
request := JoinRoomRequest{}
c.BindJSON(&request)
room := game.CreateRoom()
player := game.JoinRoom(room, request.Username)
player.SetPermissionBit(types.PermissionHost)
slog.Debug("New room created", "username", player.Username, "sessionToken", player.SessionToken, "roomId", room.RoomId.Hex())
c.JSON(http.StatusOK, player)
})
server.POST("/api/room/join", func(c *gin.Context) {
request := JoinRoomRequest{}
c.BindJSON(&request)
room := game.FindRoomByJoinCode(request.JoinCode)
if room == nil {
slog.Debug("Client tried joining room using an invalid joinCode", "joinCode", request.JoinCode)
c.JSON(http.StatusBadRequest, ErrorReply{
StatusCode: "invalid_join_code",
Message: "No valid joinCode was provided",
})
return
}
if room.GameState != types.StateLobby {
slog.Debug("Client tried joining room not in lobby state", "joinCode", request.JoinCode)
c.JSON(http.StatusBadRequest, ErrorReply{
StatusCode: "game_already_running",
Message: "You cannot join this room as the game has already started",
})
return
}
player := game.JoinRoom(room, request.Username)
slog.Debug("New session created", "username", player.Username, "sessionToken", player.SessionToken, "roomId", room.RoomId.Hex(), "joinCode", request.JoinCode)
c.JSON(http.StatusOK, player)
})
server.GET("/api/check/session", func(c *gin.Context) {
sessionToken := c.Query("sessionToken")
if sessionToken == "" {
c.JSON(http.StatusBadRequest, ErrorReply{
StatusCode: "missing_parameter",
Message: "Parameter sessionToken is missing",
})
}
_, player := game.FindSession(sessionToken)
if player == nil {
c.Status(401)
} else {
c.Status(200)
}
})
server.GET("/api/check/joinCode", func(c *gin.Context) {
joinCode := c.Query("JoinCode")
if joinCode == "" {
c.JSON(http.StatusBadRequest, ErrorReply{
StatusCode: "missing_parameter",
Message: "Parameter JoinCode is missing",
})
}
room := game.FindRoomByJoinCode(joinCode)
if room == nil {
c.Status(401)
} else {
c.Status(200)
}
})
server.POST("/api/room/leave", func(c *gin.Context) {
request := LeaveRoomRequest{}
c.BindJSON(&request)
room, player := game.FindSession(request.SessionToken)
if player == nil {
c.JSON(http.StatusBadRequest, ErrorReply{
StatusCode: "invalid_session",
Message: "No user was found with the provided sessionToken",
})
return
}
room.RemovePlayer(*player)
game.OnRoomUpdate(room)
c.Status(http.StatusOK)
})
// Handle WebSocket connections using Socket.io
wsHandler := initWS()
server.Any("/socket.io/", gin.WrapH(wsHandler))
}

26
backend/api/static.go Normal file
View File

@ -0,0 +1,26 @@
package api
import (
"embed"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func SPAMiddleware(fs embed.FS, prefix string, notFoundPath string) gin.HandlerFunc {
fileServer := http.FileServerFS(fs)
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.Next()
return
}
c.Request.URL.Path = prefix + c.Request.URL.Path
_, err := fs.Open(c.Request.URL.Path)
if err != nil {
c.Request.URL.Path = prefix + notFoundPath
}
fileServer.ServeHTTP(c.Writer, c.Request)
}
}

324
backend/api/websocket.go Normal file
View File

@ -0,0 +1,324 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/HexCardGames/HexDeck/game"
"github.com/HexCardGames/HexDeck/types"
socketio "github.com/zishang520/socket.io/v2/socket"
)
var io *socketio.Server
func initWS() http.Handler {
io = socketio.NewServer(nil, nil)
io.On("connection", func(clients ...any) {
client := clients[0].(*socketio.Socket)
remoteAddr := client.Request().Request().RemoteAddr
sessionToken, exists := client.Request().Query().Get("sessionToken")
room, player := game.FindSession(sessionToken)
if !exists || player == nil {
slog.Debug("New WebSocket connection from didn't provide a valid sessionToken -> disconnecting", "remoteAddress", remoteAddr, "sessionToken", sessionToken)
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "invalid_session",
Message: "No valid sessionToken was provided",
})
client.Disconnect(true)
return
}
if player.Connection.IsConnected && player.Connection.Socket != nil {
slog.Debug("User already connected to WebSocket -> disconnecting old socket", "remoteAddress", remoteAddr, "sessionToken", sessionToken)
player.Connection.Socket.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "connection_from_different_socket",
Message: "User connected from a different socket",
})
player.Connection.Socket.Disconnect(true)
}
player.Connection.Socket = client
player.Connection.IsConnected = true
player.ResetInactivity()
slog.Debug("New WebSocket connection", "username", player.Username, "remoteAddress", remoteAddr, "playerId", player.PlayerId, "sessionToken", sessionToken, "roomId", room.RoomId.Hex())
game.OnRoomUpdate(room)
onPlayerJoin(client, room, player)
})
return io.ServeHandler(nil)
}
func unpackData(datas []any, target interface{}) bool {
if len(datas) < 1 {
slog.Warn("Unexpected length of WebSocket data; ignoring message")
return false
}
request, _ := datas[0].(string)
ok := json.Unmarshal([]byte(request), &target)
return ok != nil
}
func verifyPlayerIsActivePlayer(room *types.Room, target *types.Player) bool {
if target.Connection.Socket == nil {
return false
}
if room.GameState != types.StateRunning {
target.Connection.Socket.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "game_not_running",
Message: "The game is not running",
})
return false
}
if !room.CardDeck.IsPlayerActive(target) {
target.Connection.Socket.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "player_not_active",
Message: "You can't execute this action while you are not the active player",
})
return false
}
return true
}
func onPlayerJoin(client *socketio.Socket, room *types.Room, player *types.Player) {
client.On("disconnect", func(...any) {
player.Connection.IsConnected = false
player.Connection.Socket = nil
slog.Debug("Player disconnected from WebSocket", "username", player.Username, "remoteAddress", client.Conn().RemoteAddress(), "sessionToken", player.SessionToken, "roomId", room.RoomId.Hex())
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()
updatePlayerRequest := types.C2S_UpdatePlayer{}
unpackData(datas, &updatePlayerRequest)
if updatePlayerRequest.PlayerId != player.PlayerId && !player.HasPermissionBit(types.PermissionHost) {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "insufficient_permission",
Message: "You can't update other users unless you are host",
})
return
}
targetPlayer := room.FindPlayer(updatePlayerRequest.PlayerId)
if targetPlayer == nil {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "invalid_player",
Message: "No player with the requested playerId was found",
})
return
}
slog.Debug("Updating player data", "roomId", room.RoomId, "playerId", updatePlayerRequest.PlayerId, "username", targetPlayer.Username, "request", updatePlayerRequest)
if player != targetPlayer {
targetPlayer.Mutex.Lock()
defer targetPlayer.Mutex.Unlock()
}
if updatePlayerRequest.Username != nil {
if room.IsUsernameAvailable(*updatePlayerRequest.Username) {
targetPlayer.Username = *updatePlayerRequest.Username
} else {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "username_taken",
Message: "The requested username is not available",
})
}
}
if updatePlayerRequest.Permissions != nil {
targetPlayer.Permissions = *updatePlayerRequest.Permissions
}
game.OnRoomUpdate(room)
})
client.On("KickPlayer", func(datas ...any) {
player.Mutex.Lock()
defer player.Mutex.Unlock()
kickPlayerRequest := types.C2S_KickPlayer{}
unpackData(datas, &kickPlayerRequest)
if !player.HasPermissionBit(types.PermissionHost) {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "insufficient_permission",
Message: "You can't update other users unless you are host",
})
return
}
targetPlayer := room.FindPlayer(kickPlayerRequest.PlayerId)
if targetPlayer == nil {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "invalid_player",
Message: "No player with the requested playerId was found",
})
return
}
if player == targetPlayer {
player.Mutex.Unlock()
}
if room.RemovePlayer(*targetPlayer) {
slog.Debug("Player was kicked from room", "playerId", player.PlayerId, "targetPlayerId", kickPlayerRequest.PlayerId, "roomId", room.RoomId)
if targetPlayer.Connection.IsConnected && targetPlayer.Connection.Socket != nil {
targetPlayer.Connection.Socket.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "player_kicked",
Message: "You were kicked from the room",
})
targetPlayer.Connection.Socket.Disconnect(true)
}
}
if player == targetPlayer {
player.Mutex.Lock()
}
game.OnRoomUpdate(room)
})
client.On("StartGame", func(datas ...any) {
if !player.HasPermissionBit(types.PermissionHost) {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "insufficient_permission",
Message: "You can't start the game unless you are host",
})
return
}
if room.GameState != types.StateLobby {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "game_already_started",
Message: "The game has already started",
})
return
}
game.StartGame(room)
})
client.On("DrawCard", func(datas ...any) {
player.Mutex.Lock()
defer player.Mutex.Unlock()
if !verifyPlayerIsActivePlayer(room, player) {
return
}
card := room.CardDeck.DrawCard()
if card == nil {
// TODO: Handle empty card deck
return
}
game.UpdateAllPlayers(room)
})
client.On("PlayCard", func(datas ...any) {
player.Mutex.Lock()
defer player.Mutex.Unlock()
if !verifyPlayerIsActivePlayer(room, player) {
return
}
updatePlayerRequest := types.C2S_PlayCard{}
unpackData(datas, &updatePlayerRequest)
if updatePlayerRequest.CardIndex == nil {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "missing_parameter",
Message: "CardIndex parameter is missing",
})
return
}
if *updatePlayerRequest.CardIndex < 0 || *updatePlayerRequest.CardIndex >= len(player.Cards) {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "invalid_card_index",
Message: "Provided CardIndex is out of bounds",
})
return
}
card := player.Cards[*updatePlayerRequest.CardIndex]
if !room.CardDeck.CanPlay(card) {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "card_not_playable",
Message: "You can't play this card now",
})
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 {
room.Winner = &player.PlayerId
game.UpdateGameState(room, types.StateEnded)
}
})
client.On("UpdatePlayedCard", func(datas ...any) {
player.Mutex.Lock()
defer player.Mutex.Unlock()
if !verifyPlayerIsActivePlayer(room, player) {
return
}
updatePlayerRequest := types.C2S_UpdatePlayedCard{}
unpackData(datas, &updatePlayerRequest)
card := room.CardDeck.UpdatePlayedCard(updatePlayerRequest.CardData)
if card == nil {
client.Emit("Status", types.S2C_Status{
IsError: true,
StatusCode: "card_not_updatable",
Message: "You can't update this card now",
})
return
}
game.OnPlayedCardUpdate(room, player, card)
})
game.SendInitialData(room, player)
}

91
backend/db/db.go Normal file
View File

@ -0,0 +1,91 @@
package db
import (
"context"
"fmt"
"log/slog"
"github.com/HexCardGames/HexDeck/types"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type DatabaseConnection struct {
client *mongo.Client
}
type GlobalStatsCollection struct {
GamesPlayed int `bson:"games_played"`
}
func (conn *DatabaseConnection) QueryRunningRooms() []*types.Room {
res, err := conn.client.Database("hexdeck").Collection("games").Find(context.TODO(), bson.D{{Key: "gamestate", Value: bson.D{{Key: "$ne", Value: types.StateEnded}}}})
if err != nil {
slog.Error("Loading rooms from database failed", "error", err)
return make([]*types.Room, 0)
}
var serializableRooms []SerializableRoom
err = res.All(context.TODO(), &serializableRooms)
if err != nil {
slog.Error("Decoding rooms from database failed", "error", err)
return make([]*types.Room, 0)
}
var rooms []*types.Room = make([]*types.Room, len(serializableRooms))
for i, serializableRoom := range serializableRooms {
room := serializableRoom.ToRoom()
rooms[i] = room
}
return rooms
}
func (conn *DatabaseConnection) InsertRoom(room *types.Room) {
_, err := conn.client.Database("hexdeck").Collection("games").InsertOne(context.TODO(), room)
if err != nil {
slog.Error("Error while inserting room into database", "error", err)
}
}
func (conn *DatabaseConnection) UpdateRoom(room *types.Room) {
result, err := conn.client.Database("hexdeck").Collection("games").UpdateByID(context.TODO(), room.RoomId, bson.D{{Key: "$set", Value: room}})
if err != nil {
slog.Error("Error while updating room in database", "error", err)
}
if result.MatchedCount < 1 {
slog.Warn(fmt.Sprintf("No collections were found while trying to update room data for room '%s'", room.RoomId))
}
}
func (conn *DatabaseConnection) IncrementGamesPlayed() {
conn.client.Database("hexdeck").Collection("global_stats").UpdateOne(context.TODO(), bson.D{}, bson.D{
{Key: "$inc", Value: bson.D{{Key: "games_played", Value: 1}}},
}, options.UpdateOne().SetUpsert(true))
}
func (conn *DatabaseConnection) QueryGlobalStats() GlobalStatsCollection {
res := conn.client.Database("hexdeck").Collection("global_stats").FindOne(context.TODO(), bson.D{})
var stats GlobalStatsCollection
res.Decode(&stats)
return stats
}
func CreateDBConnection(uri string) *DatabaseConnection {
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
slog.Error("MongoDB connection failed", "error", err)
return nil
}
return &DatabaseConnection{client}
}
var Conn DatabaseConnection
func InitDB(uri string) bool {
dbConn := CreateDBConnection(uri)
if dbConn == nil {
return false
}
Conn = *dbConn
return true
}

View File

@ -0,0 +1,72 @@
package db
import (
"sync"
"github.com/HexCardGames/HexDeck/decks"
"github.com/HexCardGames/HexDeck/types"
"go.mongodb.org/mongo-driver/v2/bson"
)
type SerializablePlayer struct {
PlayerId bson.ObjectID
SessionToken string
Username string
Permissions int
Cards []bson.D
}
func (serializable *SerializablePlayer) ToPlayer(cardDeckId int) types.Player {
cards := make([]types.Card, len(serializable.Cards))
for i, card := range serializable.Cards {
cards[i] = decks.CardFromInterface(cardDeckId, card)
}
player := types.Player{
PlayerId: serializable.PlayerId,
SessionToken: serializable.SessionToken,
Username: serializable.Username,
Permissions: serializable.Permissions,
Connection: types.WebsocketConnection{IsConnected: false},
Cards: cards,
Mutex: &sync.Mutex{},
}
player.ResetInactivity()
return player
}
type SerializableRoom struct {
RoomId bson.ObjectID `bson:"_id"`
JoinCode string
GameState types.GameState
GameOptions types.GameOptions
CardDeckId int
CardDeck bson.D
Players []SerializablePlayer
OwnerId bson.ObjectID
MoveTimeout int
Winner *bson.ObjectID
}
func (serializable SerializableRoom) ToRoom() *types.Room {
players := make([]*types.Player, len(serializable.Players))
for i, serializablePlayer := range serializable.Players {
player := serializablePlayer.ToPlayer(serializable.CardDeckId)
players[i] = &player
}
cardDeck := decks.DeckFromInterface(serializable.CardDeckId, serializable.CardDeck)
room := &types.Room{
RoomId: serializable.RoomId,
JoinCode: serializable.JoinCode,
GameState: serializable.GameState,
GameOptions: serializable.GameOptions,
CardDeckId: serializable.CardDeckId,
CardDeck: cardDeck,
Players: players,
PlayersMutex: &sync.Mutex{},
OwnerId: serializable.OwnerId,
MoveTimeout: serializable.MoveTimeout,
Winner: serializable.Winner,
}
cardDeck.SetRoom(room)
return room
}

197
backend/decks/classic.go Normal file
View File

@ -0,0 +1,197 @@
package decks
import (
"strconv"
"github.com/HexCardGames/HexDeck/types"
"github.com/HexCardGames/HexDeck/utils"
)
type Classic struct {
room *types.Room
CardsPlayed []*ClassicCard
CardsRemaining []*ClassicCard
DirectionReversed bool
ActivePlayer int
}
var ClassicColors = []string{"red", "yellow", "blue", "green"}
func (deck *Classic) fillDeck() {
cards := make([]*ClassicCard, 108)
offset := 0
for i := 0; i < 4; i++ {
color := ClassicColors[i]
for j := 0; j < 19; j++ {
cards[offset] = &ClassicCard{
Symbol: strconv.Itoa((j + 1) % 10),
Color: color,
}
offset += 1
}
for j := 0; j < 2; j++ {
cards[offset] = &ClassicCard{Symbol: "action:skip", Color: color}
cards[offset+1] = &ClassicCard{Symbol: "action:reverse", Color: color}
cards[offset+2] = &ClassicCard{Symbol: "action:draw_2", Color: color}
offset += 3
}
cards[offset] = &ClassicCard{Symbol: "action:wildcard", Color: "black"}
cards[offset+1] = &ClassicCard{Symbol: "action:draw_4", Color: "black"}
offset += 2
}
utils.ShuffleSlice(&cards)
deck.CardsRemaining = cards
}
func (deck *Classic) Init(room *types.Room) {
deck.room = room
deck.DirectionReversed = false
deck.ActivePlayer = 0
deck.fillDeck()
deck.room.PlayersMutex.Lock()
defer deck.room.PlayersMutex.Unlock()
for _, player := range deck.room.Players {
player.Mutex.Lock()
defer player.Mutex.Unlock()
deck.drawMany(player, 7)
}
}
func (deck *Classic) SetRoom(room *types.Room) {
deck.room = room
}
func (deck *Classic) IsEmpty() bool {
if len(deck.CardsRemaining) == 0 {
deck.fillDeck()
}
return false
}
func (deck *Classic) getTopCard() *ClassicCard {
if len(deck.CardsPlayed) == 0 {
return nil
}
return deck.CardsPlayed[len(deck.CardsPlayed)-1]
}
func (deck *Classic) GetTopCard() types.Card {
return deck.getTopCard()
}
func (deck *Classic) drawCard(player *types.Player) types.Card {
if deck.IsEmpty() {
return nil
}
card := deck.CardsRemaining[0]
deck.CardsRemaining = deck.CardsRemaining[1:]
player.Cards = append(player.Cards, card)
return card
}
func (deck *Classic) drawMany(player *types.Player, cards int) {
for i := 0; i < cards; i++ {
deck.drawCard(player)
}
}
func (deck *Classic) getActivePlayer() int {
return utils.Mod(deck.ActivePlayer, len(deck.room.Players))
}
func (deck *Classic) DrawCard() types.Card {
// Can't draw another card before wildcard color is selected
topCard := deck.getTopCard()
if topCard != nil && topCard.Color == "black" {
return nil
}
card := deck.drawCard(deck.room.Players[deck.getActivePlayer()])
deck.nextPlayer()
return card
}
func (deck *Classic) getNextPlayer() int {
direction := 1
if deck.DirectionReversed {
direction = -1
}
return utils.Mod((deck.ActivePlayer + direction), len(deck.room.Players))
}
func (deck *Classic) nextPlayer() {
deck.ActivePlayer = deck.getNextPlayer()
}
func (deck *Classic) CanPlay(card types.Card) bool {
topCard := deck.getTopCard()
checkCard := card.(*ClassicCard)
if topCard == nil || checkCard == nil {
return topCard == nil
}
return topCard.Color != "black" && (checkCard.Color == "black" || checkCard.Color == topCard.Color || checkCard.Symbol == topCard.Symbol)
}
func (deck *Classic) PlayCard(card types.Card) bool {
if !deck.CanPlay(card) {
return false
}
deckCard := card.(*ClassicCard)
deck.CardsPlayed = append(deck.CardsPlayed, deckCard)
if deckCard.Symbol == "action:skip" {
deck.nextPlayer()
} else if deckCard.Symbol == "action:draw_2" || deckCard.Symbol == "action:draw_4" {
targetPlayer := deck.room.Players[deck.getNextPlayer()]
amount := 2
if deckCard.Symbol == "action:draw_4" {
amount = 4
}
for range amount {
deck.drawCard(targetPlayer)
}
} else if deckCard.Symbol == "action:reverse" {
deck.DirectionReversed = !deck.DirectionReversed
}
if deckCard.Color != "black" {
deck.nextPlayer()
}
return true
}
func (deck *Classic) UpdatePlayedCard(cardData interface{}) types.Card {
topCard := deck.getTopCard()
if topCard.Color != "black" {
return nil
}
updateData, ok := cardData.(map[string]interface{})
if !ok {
return nil
}
newColor, ok := updateData["Color"].(string)
if !ok {
return nil
}
for _, color := range ClassicColors {
if newColor == color {
deck.nextPlayer()
topCard.Color = color
return topCard
}
}
return nil
}
func (deck *Classic) IsPlayerActive(target *types.Player) bool {
return deck.room.Players[utils.Mod(deck.ActivePlayer, len(deck.room.Players))] == target
}
type ClassicCard struct {
Symbol string
Color string
}

39
backend/decks/decks.go Normal file
View File

@ -0,0 +1,39 @@
package decks
import (
"github.com/HexCardGames/HexDeck/types"
"go.mongodb.org/mongo-driver/v2/bson"
)
func DeckFromInterface(cardDeckId int, cardDeck bson.D) types.CardDeck {
bsonBytes, _ := bson.Marshal(cardDeck)
switch cardDeckId {
case 0:
deck := Classic{}
bson.Unmarshal(bsonBytes, &deck)
return &deck
case 1:
deck := HexV1{}
bson.Unmarshal(bsonBytes, &deck)
return &deck
}
return nil
}
func CardFromInterface(cardDeckId int, card bson.D) types.Card {
bsonBytes, _ := bson.Marshal(card)
switch cardDeckId {
case 0:
deck := ClassicCard{}
bson.Unmarshal(bsonBytes, &deck)
return &deck
case 1:
deck := HexV1Card{}
bson.Unmarshal(bsonBytes, &deck)
return &deck
}
return nil
}

205
backend/decks/hexv1.go Normal file
View File

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

241
backend/game/game.go Normal file
View File

@ -0,0 +1,241 @@
package game
import (
"log/slog"
"math/rand/v2"
"strconv"
"sync"
"github.com/HexCardGames/HexDeck/db"
"github.com/HexCardGames/HexDeck/decks"
"github.com/HexCardGames/HexDeck/types"
"github.com/HexCardGames/HexDeck/utils"
petname "github.com/dustinkirkland/golang-petname"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
var roomsMutex sync.Mutex = sync.Mutex{}
var rooms []*types.Room = make([]*types.Room, 0)
func GenerateJoinCode() string {
code := ""
for i := 0; i < 6; i++ {
code += strconv.Itoa(rand.IntN(10))
}
return code
}
func LoadRooms() {
roomsMutex.Lock()
defer roomsMutex.Unlock()
rooms = db.Conn.QueryRunningRooms()
}
func CreateRoom() *types.Room {
newRoom := &types.Room{
RoomId: bson.NewObjectID(),
JoinCode: GenerateJoinCode(),
GameState: types.StateLobby,
Players: make([]*types.Player, 0),
PlayersMutex: &sync.Mutex{},
CardDeckId: 1,
}
db.Conn.InsertRoom(newRoom)
roomsMutex.Lock()
defer roomsMutex.Unlock()
rooms = append(rooms, newRoom)
return newRoom
}
func FindRoomByJoinCode(joinCode string) *types.Room {
for _, room := range rooms {
if room.JoinCode != joinCode {
continue
}
return room
}
return nil
}
func FindSession(sessionToken string) (*types.Room, *types.Player) {
for _, room := range rooms {
for _, player := range room.Players {
if player.SessionToken == sessionToken {
return room, player
}
}
}
return nil, nil
}
func JoinRoom(room *types.Room, requestedUsername string) *types.Player {
var username string
if requestedUsername != "" && room.IsUsernameAvailable(requestedUsername) {
username = requestedUsername
} else {
username = petname.Generate(2, " ")
}
player := &types.Player{
PlayerId: bson.NewObjectID(),
SessionToken: uuid.New().String(),
Username: username,
Permissions: 0,
Cards: make([]types.Card, 0),
Connection: types.WebsocketConnection{
IsConnected: false,
},
Mutex: &sync.Mutex{},
}
player.ResetInactivity()
room.AppendPlayer(player)
OnRoomUpdate(room)
return player
}
type GameStats struct {
RunningGames int
OnlinePlayerCount int
}
func CalculateStats() GameStats {
roomsMutex.Lock()
defer roomsMutex.Unlock()
stats := GameStats{RunningGames: 0, OnlinePlayerCount: 0}
for _, game := range rooms {
stats.RunningGames += 1
for _, player := range game.Players {
if player.Connection.IsConnected {
stats.OnlinePlayerCount += 1
}
}
}
return stats
}
func UpdateGameState(room *types.Room, newState types.GameState) {
if room.GameState != types.StateEnded && newState == types.StateEnded {
db.Conn.IncrementGamesPlayed()
}
room.GameState = newState
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 {
continue
}
player.Connection.Socket.Emit(topic, data)
}
}
func SendInitialData(room *types.Room, targetPlayer *types.Player) {
if targetPlayer.Connection.Socket == nil {
return
}
targetPlayer.Connection.Socket.Emit("OwnCards", types.BuildOwnCardsPacket(room, targetPlayer))
for _, player := range room.Players {
targetPlayer.Connection.Socket.Emit("PlayerState", types.BuildPlayerStatePacket(room, player))
}
}
func OnRoomUpdate(room *types.Room) {
db.Conn.UpdateRoom(room)
BroadcastInRoom(room, "RoomInfo", types.BuildRoomInfoPacket(room))
}
func OnPlayerStateUpdate(room *types.Room, player *types.Player, skipDBUpdate bool) {
if !skipDBUpdate {
db.Conn.UpdateRoom(room)
}
if player.Connection.Socket == nil {
return
}
player.Connection.Socket.Emit("OwnCards", types.BuildOwnCardsPacket(room, player))
BroadcastInRoom(room, "PlayerState", types.BuildPlayerStatePacket(room, player))
}
func UpdateAllPlayers(room *types.Room) {
db.Conn.UpdateRoom(room)
for _, player := range room.Players {
OnPlayerStateUpdate(room, player, true)
}
}
func OnPlayCard(room *types.Room, player *types.Player, cardIndex int, card types.Card) {
BroadcastInRoom(room, "CardPlayed", types.BuildCardPlayedPacket(player, cardIndex, card))
UpdateAllPlayers(room)
}
func OnPlayedCardUpdate(room *types.Room, player *types.Player, card types.Card) {
BroadcastInRoom(room, "PlayedCardUpdate", types.BuildPlayedCardUpdatePacket(player, card))
UpdateAllPlayers(room)
}
func StartGame(room *types.Room) {
if room.GameState != types.StateLobby {
return
}
CreateCardDeckObj(room)
room.CardDeck.Init(room)
UpdateGameState(room, types.StateRunning)
UpdateAllPlayers(room)
}
func TickRooms(deltaTime int) {
roomsMutex.Lock()
defer roomsMutex.Unlock()
for i := 0; i < len(rooms); i++ {
room := rooms[i]
hasChanged := false
room.PlayersMutex.Lock()
for j := 0; j < len(room.Players); j++ {
player := room.Players[j]
if player.Connection.IsConnected {
continue
}
if player.InactivityTimeout <= deltaTime {
slog.Debug("Removing player from room due to inactivity", "username", player.Username, "playerId", player.PlayerId.Hex(), "roomId", room.RoomId.Hex())
hasChanged = true
room.RemovePlayerUnsafe(*player)
j--
}
player.InactivityTimeout -= deltaTime
}
if len(room.Players) == 0 {
slog.Debug("Ending and unloading empty room", "roomId", room.RoomId.Hex())
UpdateGameState(room, types.StateEnded)
utils.RemoveSliceElement(&rooms, room)
i--
}
room.PlayersMutex.Unlock()
if hasChanged {
OnRoomUpdate(room)
}
}
}

66
backend/go.mod Normal file
View File

@ -0,0 +1,66 @@
module github.com/HexCardGames/HexDeck
go 1.23.4
require (
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/zishang520/socket.io/v2 v2.3.6
go.mongodb.org/mongo-driver/v2 v2.0.0
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.24.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/ginkgo/v2 v2.12.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.48.2 // indirect
github.com/quic-go/webtransport-go v0.0.0-20241018022711-4ac2c9250e66 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/zishang520/engine.io-go-parser v1.2.7 // indirect
github.com/zishang520/engine.io/v2 v2.2.5 // indirect
github.com/zishang520/socket.io-go-parser/v2 v2.2.3 // indirect
go.uber.org/mock v0.4.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

189
backend/go.sum Normal file
View File

@ -0,0 +1,189 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f h1:pDhu5sgp8yJlEF/g6osliIIpF9K4F5jvkULXa4daRDQ=
github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI=
github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
github.com/quic-go/webtransport-go v0.0.0-20241018022711-4ac2c9250e66 h1:XymiULLvtioceJngDdwgQuyjsww8V1lqsvqaKxasAb0=
github.com/quic-go/webtransport-go v0.0.0-20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zishang520/engine.io-go-parser v1.2.7 h1:pnJr/9kOmOLBJcUQpOnRfR1q3UJAQudkeF4wLyqbtnM=
github.com/zishang520/engine.io-go-parser v1.2.7/go.mod h1:WRsjNz1Oi04dqGcvjpW0t6/B2KIuDSrTBvCZDs7r3XY=
github.com/zishang520/engine.io/v2 v2.2.5 h1:10pQBreQm0LN5CKI40VvucHfGOV/IE5FeIlrCuDEBJA=
github.com/zishang520/engine.io/v2 v2.2.5/go.mod h1:XFMmS8nhF3OOpqYk6eEgATzHCs6Bkp1LuorVSk05NrQ=
github.com/zishang520/socket.io-go-parser/v2 v2.2.3 h1:kjmpDTj/j8A67/Tpc8E0jc6mDi5pERP23tQ6YNhbj+Q=
github.com/zishang520/socket.io-go-parser/v2 v2.2.3/go.mod h1:w3il6LbFRcp7cwuaez0zTGC035UnNjNjxfO0r0SECLk=
github.com/zishang520/socket.io/v2 v2.3.6 h1:TKu7OZL7T/RLpRB3XluaXvgiG4B+6RMweMU+ia8akgo=
github.com/zishang520/socket.io/v2 v2.3.6/go.mod h1:UcJJIDwGJSVVqsUOompW0xehwU+8EoUkfyW06j3Fa+k=
go.mongodb.org/mongo-driver/v2 v2.0.0 h1:Jfd7XpdZa9yk3eY774bO7SWVb30noLSirL9nKTpavhI=
go.mongodb.org/mongo-driver/v2 v2.0.0/go.mod h1:nSjmNq4JUstE8IRZKTktLgMHM4F1fccL6HGX1yh+8RA=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

63
backend/main.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"embed"
"fmt"
"log"
"log/slog"
"os"
"strconv"
"time"
"github.com/HexCardGames/HexDeck/api"
"github.com/HexCardGames/HexDeck/db"
"github.com/HexCardGames/HexDeck/game"
"github.com/HexCardGames/HexDeck/utils"
"github.com/gin-gonic/gin"
)
//go:embed all:public/*
var public embed.FS
func main() {
logHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
slog.SetDefault(slog.New(logHandler))
mongoUri := utils.Getenv("MONGO_URI", "")
if mongoUri == "" {
slog.Error("MONGO_URI environment variable not set!")
return
}
ok := db.InitDB(mongoUri)
if !ok {
slog.Error("Initializing MongoDB database failed")
return
}
game.LoadRooms()
roomTicker := time.NewTicker(1 * time.Second)
go func() {
for {
select {
case <-roomTicker.C:
game.TickRooms(1000)
}
}
}()
server := gin.Default()
server.SetTrustedProxies(nil)
api.RegisterApi(server)
server.Use(api.SPAMiddleware(public, "public", "/"))
listenHost := utils.Getenv("LISTEN_HOST", "0.0.0.0")
listenPort, err := strconv.Atoi(utils.Getenv("LISTEN_PORT", "3000"))
if err != nil {
log.Fatal("Value of variable PORT is not a valid integer!")
}
slog.Info(fmt.Sprintf("HexDeck server listening on http://%s:%d", listenHost, listenPort))
server.Run(fmt.Sprintf("%s:%d", listenHost, listenPort))
}

7
backend/public/README.md Normal file
View File

@ -0,0 +1,7 @@
# Static Frontend Hosting
All files in this directory will be merged into the server binary and served as static assets by the HexDeck server.
When building with docker, the compiled frontend will be copied here automatically.
If you are building manually, you can copy the contents of the `dist/client/` folder here after building the frontend.

146
backend/types/types.go Normal file
View File

@ -0,0 +1,146 @@
package types
import (
"sync"
"github.com/zishang520/socket.io/v2/socket"
"go.mongodb.org/mongo-driver/v2/bson"
)
type WebsocketConnection struct {
IsConnected bool
Socket *socket.Socket
}
type Card interface {
}
type CardDeck interface {
Init(*Room)
SetRoom(*Room)
IsEmpty() bool
DrawCard() Card
CanPlay(Card) bool
PlayCard(Card) bool
GetTopCard() Card
UpdatePlayedCard(interface{}) Card
IsPlayerActive(*Player) bool
}
type Player struct {
PlayerId bson.ObjectID
SessionToken string
Username string
Permissions int
Cards []Card `json:"-"`
Connection WebsocketConnection `bson:"-" json:"-"`
InactivityTimeout int `bson:"-" json:"-"`
Mutex *sync.Mutex
}
func (player *Player) ResetInactivity() {
player.InactivityTimeout = 20 * 1000
}
func (player *Player) SetPermissionBit(bit RoomPermission) {
player.Permissions |= (1 << bit)
}
func (player *Player) ClearPermissionBit(bit RoomPermission) {
player.Permissions &= ^(1 << bit)
}
func (player *Player) HasPermissionBit(bit RoomPermission) bool {
return player.Permissions&(1<<bit) > 0
}
type GameState int
const (
StateLobby GameState = iota
StateRunning
StateEnded
)
type RoomPermission int
const (
PermissionHost RoomPermission = 0
)
type GameOptions struct {
}
type Room struct {
RoomId bson.ObjectID `bson:"_id"`
JoinCode string
GameState GameState
GameOptions GameOptions
CardDeckId int
CardDeck CardDeck
Players []*Player
PlayersMutex *sync.Mutex `bson:"-"`
OwnerId bson.ObjectID
MoveTimeout int
Winner *bson.ObjectID
}
func (room *Room) AppendPlayer(player *Player) {
room.PlayersMutex.Lock()
defer room.PlayersMutex.Unlock()
room.Players = append(room.Players, player)
}
func (room *Room) RemovePlayer(target Player) bool {
room.PlayersMutex.Lock()
defer room.PlayersMutex.Unlock()
target.Mutex.Lock()
defer target.Mutex.Unlock()
return room.RemovePlayerUnsafe(target)
}
func (room *Room) FindPlayer(playerId bson.ObjectID) *Player {
room.PlayersMutex.Lock()
defer room.PlayersMutex.Unlock()
for _, player := range room.Players {
if player.PlayerId == playerId {
return player
}
}
return nil
}
func (room *Room) RemovePlayerUnsafe(target Player) bool {
foundHost := false
foundPlayer := false
for i := 0; i < len(room.Players); i++ {
player := room.Players[i]
if player.PlayerId == target.PlayerId {
room.Players = append(room.Players[:i], room.Players[i+1:]...)
foundPlayer = true
i--
continue
}
if player.HasPermissionBit(PermissionHost) {
foundHost = true
}
}
if !foundPlayer {
return false
}
if !foundHost && len(room.Players) > 0 {
room.Players[0].SetPermissionBit(PermissionHost)
}
return true
}
func (room *Room) IsUsernameAvailable(username string) bool {
for _, player := range room.Players {
if player.Username == username {
return false
}
}
return true
}

View File

@ -0,0 +1,120 @@
package types
import (
"go.mongodb.org/mongo-driver/v2/bson"
)
type S2C_Status struct {
IsError bool
StatusCode string
Message string
}
type S2C_PlayerInfo struct {
PlayerId bson.ObjectID
Username string
Permissions int
IsConnected bool
}
type S2C_RoomInfo struct {
RoomId bson.ObjectID `bson:"_id"`
JoinCode string
GameState GameState
GameOptions GameOptions
TopCard Card
CardDeckId int
Winner *bson.ObjectID
Players []S2C_PlayerInfo
}
type S2C_Card struct {
CanPlay bool
Card Card
}
type S2C_OwnCards struct {
Cards []S2C_Card
}
type S2C_PlayerState struct {
PlayerId bson.ObjectID
NumCards int
Active bool
}
type S2C_CardPlayed struct {
Card Card
CardIndex int
PlayedBy bson.ObjectID
}
type S2C_PlayedCardUpdate struct {
UpdatedBy bson.ObjectID
Card Card
}
type C2S_SetCardDeck struct {
CardDeckId int
}
type C2S_UpdatePlayer struct {
PlayerId bson.ObjectID
Username *string
Permissions *int
}
type C2S_KickPlayer struct {
PlayerId bson.ObjectID
}
type C2S_PlayCard struct {
CardIndex *int
CardData interface{}
}
type C2S_UpdatePlayedCard struct {
CardData interface{}
}
func BuildRoomInfoPacket(room *Room) S2C_RoomInfo {
players := make([]S2C_PlayerInfo, len(room.Players))
for i, player := range room.Players {
players[i] = S2C_PlayerInfo{
PlayerId: player.PlayerId,
Username: player.Username,
Permissions: player.Permissions,
IsConnected: player.Connection.IsConnected,
}
}
roomInfo := S2C_RoomInfo{
RoomId: room.RoomId,
JoinCode: room.JoinCode,
GameState: room.GameState,
CardDeckId: room.CardDeckId,
GameOptions: room.GameOptions,
Winner: room.Winner,
Players: players,
}
if room.CardDeck != nil {
roomInfo.TopCard = room.CardDeck.GetTopCard()
}
return roomInfo
}
func BuildOwnCardsPacket(room *Room, player *Player) S2C_OwnCards {
cards := make([]S2C_Card, len(player.Cards))
for i, card := range player.Cards {
cards[i] = S2C_Card{
Card: card,
CanPlay: room.CardDeck.CanPlay(card),
}
}
return S2C_OwnCards{
Cards: cards,
}
}
func BuildPlayerStatePacket(room *Room, player *Player) S2C_PlayerState {
isActivePlayer := false
if room.CardDeck != nil && room.CardDeck.IsPlayerActive(player) {
isActivePlayer = true
}
return S2C_PlayerState{PlayerId: player.PlayerId, NumCards: len(player.Cards), Active: isActivePlayer}
}
func BuildCardPlayedPacket(player *Player, cardIndex int, card Card) S2C_CardPlayed {
return S2C_CardPlayed{Card: card, CardIndex: cardIndex, PlayedBy: player.PlayerId}
}
func BuildPlayedCardUpdatePacket(player *Player, card Card) S2C_PlayedCardUpdate {
return S2C_PlayedCardUpdate{UpdatedBy: player.PlayerId, Card: card}
}

41
backend/utils/utils.go Normal file
View File

@ -0,0 +1,41 @@
package utils
import (
"os"
"golang.org/x/exp/rand"
)
func Getenv(key string, fallback string) string {
value, exists := os.LookupEnv(key)
if exists {
return value
} else {
return fallback
}
}
func RemoveSliceElement[T comparable](slice *([]T), target T) bool {
for i, el := range *slice {
if el == target {
*slice = append((*slice)[:i], (*slice)[i+1:]...)
return true
}
}
return false
}
func ShuffleSlice[T any](slice *([]T)) {
length := len(*slice)
for i := 0; i < length; i++ {
j := rand.Intn(i + 1)
(*slice)[i], (*slice)[j] = (*slice)[j], (*slice)[i]
}
}
func Mod(a, b int) int {
if b == 0 {
return 0
}
return (a%b + b) % b
}

8
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,8 @@
services:
mongodb-dev:
user: "1000"
ports:
- 27017:27017
volumes:
- ./data/mongodb-dev/:/data/db/
image: mongodb/mongodb-community-server:latest

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
services:
mongodb:
user: "1000"
volumes:
- ./data/mongodb/:/data/db/
image: mongodb/mongodb-community-server:latest
command: mongod --bind_ip_all
hexdeck:
user: "1000"
ports:
- 3000:3000
environment:
- MONGO_URI=mongodb://mongodb:27017/
depends_on:
- mongodb
build: .

26
frontend/.dockerignore Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.routify/*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

2
frontend/.env.sample Normal file
View File

@ -0,0 +1,2 @@
# Backend URL to use for the vite development proxy
VITE_BACKEND_URL=http://localhost:3000

26
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.routify/*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
frontend/.prettierrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 200,
"bracketSpacing": true,
"arrowParens": "always",
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

47
frontend/README.md Normal file
View File

@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from "svelte/store";
export default writable(0);
```

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/hexagon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5112
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "hexdeck-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^22.13.9",
"flowbite": "^3.1.2",
"flowbite-svelte": "^0.48.4",
"flowbite-svelte-icons": "^2.0.2",
"prettier": "3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.85.1",
"svelte": "^5.22.5",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
},
"dependencies": {
"@roxi/routify": "3.0.0-next.254",
"@tailwindcss/vite": "^4.0.10",
"lucide-svelte": "^0.477.0",
"routify": "^2.0.1",
"socket.io-client": "^4.8.1",
"svelte-exmarkdown": "^4.0.3",
"svelte-i18n": "^4.0.1",
"tailwindcss": "^4.0.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}

3380
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hexagon"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>

After

Width:  |  Height:  |  Size: 350 B

14
frontend/src/App.svelte Normal file
View File

@ -0,0 +1,14 @@
<script context="module">
import { Router, createRouter } from "@roxi/routify";
import routes from "../.routify/routes.default.js";
import MetaTitle from "./components/meta-title.svelte";
export const router = createRouter({ routes });
router.ready().then(() => {
console.log("Routify router is ready!");
});
</script>
<MetaTitle />
<Router {router} />

90
frontend/src/app.scss Normal file
View File

@ -0,0 +1,90 @@
@theme inline {
--color-primary-50: color-mix(in srgb, var(--primary) 10%, white);
--color-primary-100: color-mix(in srgb, var(--primary) 20%, white);
--color-primary-200: color-mix(in srgb, var(--primary) 30%, white);
--color-primary-300: color-mix(in srgb, var(--primary) 40%, white);
--color-primary-400: color-mix(in srgb, var(--primary) 50%, white);
--color-primary-500: color-mix(in srgb, var(--primary) 60%, white);
--color-primary-600: color-mix(in srgb, var(--primary) 70%, white);
--color-primary-700: color-mix(in srgb, var(--primary) 80%, white);
--color-primary-800: color-mix(in srgb, var(--primary) 90%, white);
--color-primary-900: var(--primary);
--color-primary-950: color-mix(in srgb, var(--primary) 90%, black);
--color-secondary-50: color-mix(in srgb, var(--secondary) 10%, white);
--color-secondary-100: color-mix(in srgb, var(--secondary) 20%, white);
--color-secondary-200: color-mix(in srgb, var(--secondary) 30%, white);
--color-secondary-300: color-mix(in srgb, var(--secondary) 40%, white);
--color-secondary-400: color-mix(in srgb, var(--secondary) 50%, white);
--color-secondary-500: color-mix(in srgb, var(--secondary) 60%, white);
--color-secondary-600: color-mix(in srgb, var(--secondary) 70%, white);
--color-secondary-700: color-mix(in srgb, var(--secondary) 80%, white);
--color-secondary-800: color-mix(in srgb, var(--secondary) 90%, white);
--color-secondary-900: var(--secondary);
--color-secondary-950: color-mix(in srgb, var(--secondary) 90%, black);
--color-tertiary-50: color-mix(in srgb, var(--tertiary) 10%, white);
--color-tertiary-100: color-mix(in srgb, var(--tertiary) 20%, white);
--color-tertiary-200: color-mix(in srgb, var(--tertiary) 30%, white);
--color-tertiary-300: color-mix(in srgb, var(--tertiary) 40%, white);
--color-tertiary-400: color-mix(in srgb, var(--tertiary) 50%, white);
--color-tertiary-500: color-mix(in srgb, var(--tertiary) 60%, white);
--color-tertiary-600: color-mix(in srgb, var(--tertiary) 70%, white);
--color-tertiary-700: color-mix(in srgb, var(--tertiary) 80%, white);
--color-tertiary-800: color-mix(in srgb, var(--tertiary) 90%, white);
--color-tertiary-900: var(--tertiary);
--color-tertiary-950: color-mix(in srgb, var(--tertiary) 90%, black);
}
@layer base {
button,
[role="button"] {
cursor: pointer;
}
}
body {
margin: 0;
color: var(--default-element-color);
background-color: var(--default-background-color);
transition:
color 0.4s ease,
background-color 0.2s ease;
}
:root {
font-family: "Lexend Deca", serif;
font-optical-sizing: auto;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--primary: #d5b6ff;
--secondary: #ffb6f5;
--tertiary: #08aeea;
--default-element-color: #213547;
--default-background-color: #ffffff;
}
body.dark-theme {
--primary: #53346a;
--secondary: #710cff;
--tertiary: #0b465c;
--default-element-color: rgba(255, 255, 255, 0.87);
--default-background-color: oklch(0.279 0.041 260.031);
}
body.light-theme {
--primary: #d5b6ff;
--secondary: #ffb6f5;
--tertiary: #08aeea;
--default-element-color: #213547;
--default-background-color: #ffffff;
}

View File

@ -0,0 +1,29 @@
<script lang="ts">
import CardDisplay from "./Game/CardDisplay.svelte";
export let cardComponent;
export let cardDeckId: number;
export let cardWidth: number;
export let cardHeight: number;
export let centerDistancePx: number;
export let maxRotationDeg: number;
const SHOWCASED_CARDS: any = {
0: [
{ CanPlay: true, Card: { Symbol: "1", Color: "red" } },
{ CanPlay: true, Card: { Symbol: "9", Color: "green" } },
{ CanPlay: true, Card: { Symbol: "action:draw_2", Color: "blue" } },
{ CanPlay: true, Card: { Symbol: "action:skip", Color: "yellow" } },
{ CanPlay: true, Card: { Symbol: "action:draw_4", Color: "black" } },
],
1: [
{ CanPlay: true, Card: { Symbol: "1", Color: "blue" } },
{ CanPlay: true, Card: { Symbol: "F", Color: "green" } },
{ CanPlay: true, Card: { Symbol: "action:shuffle", Color: "yellow" } },
{ CanPlay: true, Card: { Symbol: "action:skip", Color: "purple" } },
{ CanPlay: true, Card: { Symbol: "action:draw", Color: "rainbow" } },
],
};
</script>
<CardDisplay canPlayCards={true} canUpdateCards={false} {cardComponent} cards={SHOWCASED_CARDS[cardDeckId]} {cardWidth} {cardHeight} {centerDistancePx} {maxRotationDeg} />

View File

@ -0,0 +1,109 @@
<script lang="ts">
import type { Card } from "../../stores/sessionStore";
interface ClassicCard {
Symbol: string;
Color: string;
}
export let data: ClassicCard = { Color: "", Symbol: "" };
export let canUpdateCard: boolean = false;
export let updateCard: (newCard: Card) => void = () => {};
export let width: number;
export let height: number;
const COLOR_MAP: { [key: string]: string } = {
red: "0#990000",
green: "#009900",
blue: "#000099",
yellow: "#999900",
black: "#000000",
"": "#808080",
};
function selectColor(color: string) {
let newCard: ClassicCard = {
Color: color,
Symbol: data.Symbol,
};
updateCard(newCard);
}
</script>
<div class="card" style:background={COLOR_MAP[data.Color]} style:--width={`${width}px`} style:--height={`${height}px`}>
{#if data.Symbol.length <= 2}
<span class="symbol">{data.Symbol}</span>
{:else}
<span class="symbol large">{data.Symbol.split(":")[1].replace("_", " ")}</span>
{/if}
{#if data.Color == "black" && canUpdateCard}
<div class="select mt-5">
<div>
<button
class="bg-red-400"
aria-label="Red"
on:click={() => {
selectColor("red");
}}
></button>
<button
class="bg-green-400"
aria-label="Green"
on:click={() => {
selectColor("green");
}}
></button>
</div>
<div>
<button
class="bg-blue-400"
aria-label="Blue"
on:click={() => {
selectColor("blue");
}}
></button>
<button
class="bg-yellow-400"
aria-label="Yellow"
on:click={() => {
selectColor("yellow");
}}
></button>
</div>
</div>
{/if}
</div>
<style>
.card {
display: flex;
flex-direction: column;
width: var(--width);
height: var(--height);
padding: 5px;
border-radius: 8px;
}
.select button {
width: 20px;
height: 20px;
border-radius: 30px;
}
.card span {
text-align: left;
word-wrap: anywhere;
}
.card .symbol {
font-size: 25px;
text-transform: capitalize;
}
.card .symbol.large {
font-size: 17px;
}
button {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,110 @@
<script lang="ts">
import type { Card } from "../../stores/sessionStore";
interface ClassicCard {
Symbol: string;
Color: string;
}
export let data: ClassicCard = { Color: "", Symbol: "" };
export let canUpdateCard: boolean = false;
export let updateCard: (newCard: Card) => void = () => {};
export let width: number;
export let height: number;
const COLOR_MAP: { [key: string]: string } = {
blue: "#009bff",
green: "#00c841",
yellow: "#ffa500",
purple: "#7300c8",
rainbow: "#ababab",
black: "#000000",
"": "#999",
};
function selectColor(color: string) {
let newCard: ClassicCard = {
Color: color,
Symbol: data.Symbol,
};
updateCard(newCard);
}
</script>
<div class="card" style:background={COLOR_MAP[data.Color]} style:--width={`${width}px`} style:--height={`${height}px`}>
{#if data.Symbol.length <= 2}
<span class="symbol">{data.Symbol}</span>
{:else}
<span class="symbol large">{data.Symbol.split(":")[1].replace("_", " ")}</span>
{/if}
{#if data.Color == "rainbow" && canUpdateCard}
<div class="select mt-5">
<div>
<button
style:background={COLOR_MAP["blue"]}
aria-label="Blue"
on:click={() => {
selectColor("blue");
}}
></button>
<button
style:background={COLOR_MAP["green"]}
aria-label="Green"
on:click={() => {
selectColor("green");
}}
></button>
</div>
<div>
<button
style:background={COLOR_MAP["yellow"]}
aria-label="Yellow"
on:click={() => {
selectColor("yellow");
}}
></button>
<button
style:background={COLOR_MAP["purple"]}
aria-label="Purple"
on:click={() => {
selectColor("purple");
}}
></button>
</div>
</div>
{/if}
</div>
<style>
.card {
display: flex;
flex-direction: column;
width: var(--width);
height: var(--height);
padding: 5px;
border-radius: 8px;
}
.select button {
width: 20px;
height: 20px;
border-radius: 30px;
}
.card span {
text-align: left;
word-wrap: anywhere;
}
.card .symbol {
font-size: 25px;
text-transform: capitalize;
}
.card .symbol.large {
font-size: 17px;
}
button {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,172 @@
<script lang="ts">
import { onMount } from "svelte";
import { Button, Spinner, InputAddon, ButtonGroup, Helper } from "flowbite-svelte";
import { _ } from "svelte-i18n";
import { loading, join_error, create_error, rejoinRoomCode, rejoinRoomSessionData, requestJoinRoom, requestCreateRoom, joinSession, checkSessionData } from "../stores/roomStore";
let joinRoomId = "";
let inputRef: HTMLInputElement | null = null;
function formatInput(event: any) {
let rawValue = event.target.value.replace(/\D/g, "");
join_error.set(false);
if (rawValue.length > 6) {
rawValue = rawValue.slice(0, 6);
}
let formattedValue = rawValue.replace(/(\d{3})(\d{0,3})/, "$1-$2").trim();
joinRoomId = formattedValue;
if (joinRoomId.length > 6) {
requestJoinRoom(joinRoomId);
}
}
function handleKeyDown(event: any) {
if (event.key === "Backspace") {
join_error.set(false);
let cursorPosition = event.target.selectionStart;
if (cursorPosition === 4) {
joinRoomId = joinRoomId.slice(0, 2);
event.preventDefault();
}
}
}
function focusInput() {
inputRef?.focus();
}
onMount(() => {
focusInput();
checkSessionData();
});
</script>
<!-- TODO dedicated rejoinRoom and rejoinSession loading states & error messages -->
<div class="w-full max-w-md">
{#if $rejoinRoomSessionData?.sessionToken}
<div class="mb-6">
<Button
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
size="lg"
disabled={$loading && $loading != "create"}
on:click={() => joinSession($rejoinRoomSessionData?.sessionToken, $rejoinRoomSessionData?.userId)}
>
{#if $loading == "create"}
<Spinner class="text-primary-350 dark:text-primary-200 me-3" size="4" />
{/if}
{$_("landing_page.connect_room.rejoin_last_room")}
</Button>
{#if $create_error}
<Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${$create_error}`, {
default: $_("error_messages.error_message", {
values: { error_message: $create_error },
}),
})}
</span>
</Helper>
{/if}
</div>
<div class="w-full text-center mb-4">
{$_("landing_page.connect_room.or")}
</div>
{/if}
{#if $rejoinRoomCode}
<div class="mb-6">
<Button
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
size="lg"
disabled={$loading && $loading != "create"}
on:click={() => requestJoinRoom($rejoinRoomCode)}
>
{#if $loading == "create"}
<Spinner class="text-primary-350 dark:text-primary-200 me-3" size="4" />
{/if}
{$_("landing_page.connect_room.join_last_room")}
</Button>
{#if $create_error}
<Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${$create_error}`, {
default: $_("error_messages.error_message", {
values: { error_message: $create_error },
}),
})}
</span>
</Helper>
{/if}
</div>
<div class="w-full text-center mb-4">
{$_("landing_page.connect_room.or")}
</div>
{/if}
<div class="mb-4">
<ButtonGroup class="w-full overflow-hidden rounded-xl border-1 border-primary-800" size="sm">
<InputAddon class="w-10 text-center bg-primary-400 dark:bg-primary-800">
{#if $loading == "join"}
<div class="w-full flex justify-center">
<Spinner class="text-primary-350 dark:text-primary-200" size="4.2" />
</div>
{:else}
<span class="w-full"> # </span>
{/if}
</InputAddon>
<input
class="bg-primary-200 px-4 focus:bg-primary-100 placeholder:text-gray-500 dark:placeholder:text-gray-300 mr-md dark:bg-primary-700 dark:focus:bg-primary-600 w-full border-0 focus:outline-none focus:ring-0 h-12"
placeholder={$_("landing_page.connect_room.enter_room_code")}
class:cursor-not-allowed={$loading}
class:opacity-50={$loading}
disabled={!!$loading}
bind:this={inputRef}
bind:value={joinRoomId}
on:input={formatInput}
on:keydown={handleKeyDown}
/>
</ButtonGroup>
{#if $join_error}
<Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${$join_error}`, {
default: $_("error_messages.error_message", {
values: { error_message: $join_error },
}),
})}
</span>
</Helper>
{/if}
</div>
<div class="w-full text-center mb-4">
{$_("landing_page.connect_room.or")}
</div>
<div class="mb-6">
<Button
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
size="lg"
disabled={$loading && $loading != "create"}
on:click={requestCreateRoom}
>
{#if $loading == "create"}
<Spinner class="text-primary-350 dark:text-primary-200 me-3" size="4" />
{/if}
{$_("landing_page.connect_room.create_a_room")}
</Button>
{#if $create_error}
<Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${$create_error}`, {
default: $_("error_messages.error_message", {
values: { error_message: $create_error },
}),
})}
</span>
</Helper>
{/if}
</div>
</div>

View File

@ -0,0 +1,29 @@
<script>
import { Banner, Button, Tooltip } from "flowbite-svelte";
import { ArrowUpFromLine, Dot } from "lucide-svelte";
import { slide } from "svelte/transition";
import { quintOut } from "svelte/easing";
import { _ } from "svelte-i18n";
let bannerStatus = true;
const params = { delay: 250, duration: 500, easing: quintOut };
function showFooter() {
bannerStatus = true;
}
</script>
<Banner id="bottom-banner" position="absolute" bannerType="bottom" transition={slide} {params} bind:bannerStatus>
<p class="flex items-center text-sm font-normal text-gray-500 dark:text-gray-400">
<a href="/imprint">{$_("footer.imprint")}</a>
<Dot />
<a href="https://github.com/HexCardGames/HexDeck" target="_blank">{$_("footer.github")}</a>
</p>
</Banner>
<div class="absolute right-0 bottom-0">
<Button on:click={showFooter} class="!p-2 focus:ring-0 mt-2" color="none">
<ArrowUpFromLine />
</Button>
<Tooltip type="auto">{$_("landing_page.show_footer")}</Tooltip>
</div>

View File

@ -0,0 +1,91 @@
<script lang="ts">
import type { Card, CardInfoObj, CardPlayedObj } from "../../stores/sessionStore";
export let cardWidth: number;
export let cardHeight: number;
export let cards: CardInfoObj[] | CardPlayedObj[];
export let cardComponent;
export let click: (index: number) => void = () => {};
export let updateCard: (index: number, newCard: Card) => void = () => {};
export let canPlayCards: boolean = true;
export let canUpdateCards: boolean = false;
export let centerDistancePx: number;
export let maxRotationDeg: number;
export let fullwidth: boolean = false;
export let hoverOffset: number = canPlayCards ? 40 : 0;
let rotationDeg = 0;
$: rotationDeg = Math.min(maxRotationDeg, 1.5 * cards.length);
let maxOffset = [0, 0];
let offset = [0, 0];
$: if (centerDistancePx && rotationDeg) maxOffset = getCardOffset(getCardRotation(0, cards.length, maxRotationDeg));
$: if (centerDistancePx && rotationDeg) offset = getCardOffset(getCardRotation(0, cards.length, rotationDeg));
function getCardRotation(i: number, totalCards: number, maxRotationDeg: number): number {
if (totalCards == 1) return 0;
return -maxRotationDeg + (i / (totalCards - 1)) * 2 * maxRotationDeg;
}
function getCardOffset(angle: number): [number, number] {
let slope = Math.tan(angle * (Math.PI / 180));
let y = Math.sqrt(slope ** 2 + 1) * (centerDistancePx / (slope ** 2 + 1));
let x = slope * y;
return [x, centerDistancePx - y];
}
</script>
<div
class="cards relative flex justify-center box-content"
class:fullwidth
style:--height={`${maxOffset[1] + cardHeight}px`}
style:--width={`${-offset[0] * 2 + cardWidth}px`}
style:--hover-offset={`${hoverOffset}px`}
>
{#each cards as cardInfo, i}
{@const rotation = getCardRotation(i, cards.length, rotationDeg)}
{@const position = getCardOffset(rotation)}
<button
class="absolute card drop-shadow-lg"
disabled={!(cardInfo as CardInfoObj).CanPlay}
on:click={() => click(i)}
class:canPlayCards
style:--rotation={`${rotation}deg`}
style:--left={`${position[0]}px`}
style:--top={`${position[1]}px`}
>
<svelte:component
this={cardComponent}
width={cardWidth}
height={cardHeight}
data={cardInfo.Card}
canUpdateCard={canUpdateCards}
updateCard={(newCard: Card) => {
updateCard(i, newCard);
}}
/>
</button>
{/each}
</div>
<style>
.cards {
height: var(--height);
width: var(--width);
padding-top: var(--hover-offset);
}
.card {
transform: translate(var(--left), var(--top)) rotate(var(--rotation));
transition: 0.2s transform;
cursor: unset;
user-select: none;
}
.card.canPlayCards:enabled {
cursor: pointer;
}
.card.canPlayCards:disabled {
filter: brightness(0.4);
}
.card.canPlayCards:hover {
transform: translate(var(--left), var(--top)) rotate(var(--rotation)) translate(0px, calc(0px - var(--hover-offset)));
}
</style>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import type { PlayerObj, PlayerStateObj } from "../../stores/sessionStore";
import CardDisplay from "./CardDisplay.svelte";
export let player: PlayerObj;
export let state: PlayerStateObj | undefined;
export let cardComponent;
export let cardWidth: number;
export let cardHeight: number;
export let centerDistancePx;
export let maxRotationDeg;
export let rotationDeg;
</script>
{#if state}
<div class="opponentDisplay flex flex-col justify-center items-center" style:--rotation={`${rotationDeg}deg`}>
<p class:font-bold={state?.Active}>{player.Username}</p>
<CardDisplay
{cardComponent}
{cardWidth}
{cardHeight}
cards={Array(state?.NumCards).fill({ Card: undefined })}
canPlayCards={false}
canUpdateCards={false}
hoverOffset={0}
{centerDistancePx}
{maxRotationDeg}
/>
</div>
{/if}
<style>
.opponentDisplay {
transform: rotate(var(--rotation));
}
</style>

View File

@ -0,0 +1,81 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { sessionStore } from "../stores/sessionStore";
import { ButtonGroup, InputAddon, Spinner } from "flowbite-svelte";
import { _ } from "svelte-i18n";
export let playerId: string = sessionStore.getUserId() || "";
let playerName: string = sessionStore.getUser(playerId)?.Username || "ERROR USERNAME NOT FOUND";
let isLoading: boolean = false;
let debounceTimer: NodeJS.Timeout | null = null;
let loadingTimer: NodeJS.Timeout | null = null;
let inputRef: HTMLInputElement | null = null;
function onInput(event: Event) {
const newName = (event.target as HTMLInputElement).value;
playerName = newName;
if (debounceTimer) clearTimeout(debounceTimer);
if (loadingTimer) {
clearTimeout(loadingTimer);
isLoading = false;
}
// Start loading spinner after 0.8s
loadingTimer = setTimeout(() => {
isLoading = true;
}, 800);
// Start a debounce timer (3s)
debounceTimer = setTimeout(() => {
sessionStore.renamePlayer(playerId, newName);
isLoading = false;
unfocusInput();
}, 1800);
}
function focusInput() {
console.log(focusInput);
inputRef?.focus();
}
function unfocusInput() {
console.log(focusInput);
inputRef?.blur();
}
onDestroy(() => {
if (debounceTimer) clearTimeout(debounceTimer);
if (loadingTimer) clearTimeout(loadingTimer);
});
</script>
<div
class="group w-xs mx-auto text-dark bg-gray-100 dark:bg-gray-900 focus-within:bg-gray-50 dark:focus-within:bg-gray-800 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all"
role="button"
tabindex="0"
on:click={focusInput}
on:keydown={(event) => {
if (event.key === "Enter" || event.key === " ") focusInput();
}}
>
<div class="grid justify-items-start w-full">
{#if playerId == sessionStore.getUserId()}
<span class="text-sm">{$_("lobby.rename_yourself")}</span>
{:else}
<span class="text-sm">{$_("lobby.rename_player")}</span>
{/if}
<!-- Rename Player -->
<div class="w-full">
<input class="text-black w-full dark:text-white mr-md w-full border-0 focus:outline-none focus:ring-0 h-8 bg-transparent" bind:value={playerName} on:input={onInput} bind:this={inputRef} />
</div>
</div>
{#if isLoading}
<div class="">
<Spinner class="text-primary-350 w-8 h-8 dark:text-primary-200" />
</div>
{/if}
</div>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import { ChartNoAxesCombined } from "lucide-svelte";
import { onDestroy, onMount } from "svelte";
import { _ } from "svelte-i18n";
let stats = {
online_player_count: null,
current_game_rooms: null,
games_played: null,
};
async function getStats() {
try {
const res = await fetch("/api/stats");
const resJson = await res.json();
stats.online_player_count = resJson?.OnlinePlayerCount;
stats.current_game_rooms = resJson?.RunningGames;
stats.games_played = resJson?.TotalGamesPlayed;
} catch {}
}
let getStateInterval: any = undefined;
onMount(() => {
getStats();
// Request stats update every 10s
getStateInterval = setInterval(getStats, 10 * 60 * 1000);
});
onDestroy(() => {
clearInterval(getStateInterval);
});
</script>
<div class="p-4 bg-primary-50 dark:bg-primary-950 rounded-xl grid content-start justify-items-center w-3xs text-center space-y-2 border-1 border-primary-200 dark:border-primary-800">
<ChartNoAxesCombined size="48px" />
<h4 class="text-xl font-semibold">{$_("landing_page.stats_container.title")}</h4>
<!-- content div -->
<div class="grid justify-items-start text-start">
<span>{$_("landing_page.stats_container.online_player_count", { values: { count: stats.online_player_count ?? "..." } })}</span>
<span>{$_("landing_page.stats_container.current_game_rooms", { values: { count: stats.current_game_rooms ?? "..." } })}</span>
<span>{$_("landing_page.stats_container.games_played", { values: { count: stats.games_played ?? "..." } })}</span>
</div>
</div>

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { _, waitLocale } from "svelte-i18n";
import { onMount } from "svelte";
let title = "Loading...";
onMount(async () => {
// Ensure translations are loaded
await waitLocale();
title = $_("page_name");
});
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>

116
frontend/src/i18n/de.json Normal file
View File

@ -0,0 +1,116 @@
{
"page_name": "HexDeck",
"header": {
"theme_btn": {
"tooltip": "Thema wechseln: {current_theme}",
"dark": "Dunkel",
"light": "Hell",
"system": "System"
}
},
"footer": {
"imprint": "Impressum",
"github": "GitHub"
},
"404": {
"404_page_not_found": "404 - Seite nicht gefunden",
"page_not_found": "Die Seite {page} konnte nicht gefunden werden."
},
"imprint": {
"title": "Impressum",
"something_went_wrong": "Etwas ist schief gelaufen",
"timeout_while_loading": "Zeitüberschreitung beim Laden",
"retry": "Erneut versuchen",
"go_back": "Zurück"
},
"landing_page": {
"sub_title": "Multiplayer, kostenlos, für alle",
"connect_room": {
"rejoin_last_room": "Letztes Spiel erneut beitreten",
"join_last_room": "Letztem Raum beitreten",
"enter_room_code": "Raumcode eingeben",
"join_room": "Beitreten",
"enter_room_code_to_join": "Bitte geben Sie einen Raumcode ein, um beizutreten",
"or": "oder",
"create_a_room": "Einen Raum erstellen"
},
"open_source_container": {
"title": "Open Source",
"content": "Der Quellcode dieses Spiels ist auf GitHub verfügbar",
"github": "GitHub"
},
"stats_container": {
"title": "Statistiken",
"online_player_count": "Aktuelle Spieler: {count}",
"current_game_rooms": "Aktuelle Spiele: {count}",
"games_played": "Gespielte Spiele: {count}",
"no_data": "Keine Daten"
},
"show_footer": "Footer anzeigen"
},
"lobby": {
"search_player": "Spieler suchen...",
"kick_player": "Spieler entfernen",
"confirm_kick_player_message": "Möchten Sie den Spieler {player_name} wirklich entfernen?",
"confirm_kick_player": "Entfernen",
"rename_yourself": "Sich selbst umbenennen",
"rename_player": "Spieler umbenennen",
"regenerate_join_code": "Beitrittscode neu generieren",
"copy_join_code": "Beitrittscode kopieren",
"room_join_code": "Raum Beitrittscode",
"copy_code": "Code kopieren",
"copy_join_link": "Link kopieren",
"leave_game": "Spiel verlassen",
"confirm_leave_message": "Möchten Sie das Spiel wirklich verlassen?",
"confirm_leave": "Ja, verlassen",
"cancel": "Abbrechen",
"start_game": "Spiel starten",
"copied": "Kopiert",
"player_name": "Spielername",
"status": "Status",
"host": "Host",
"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",
"player_won_the_game": "{player_name} hat das Spiel gewonnen!",
"you_won_the_game": "Du hast das Spiel gewonnen!",
"go_back": "Zurück",
"play_again": "Erneut spielen"
},
"player_status": {
"connected": "Verbunden",
"disconnected": "Getrennt"
},
"game_status": {
"game_status": "Spielstatus:",
"lobby": "Lobby",
"running": "Läuft",
"ended": "Beendet"
},
"error_messages": {
"no_room_found": "Kein Raum mit diesem Code gefunden",
"request_timeout": "Internet fehlgeschlagen! (Zeitüberschreitung)",
"invalid_player": "Ungültiger Spieler",
"invalid_session": "Ungültige Sitzung",
"game_not_running": "Das Spiel läuft nicht",
"player_not_active": "Der Spieler ist nicht aktiv",
"insufficient_permission": "Unzureichende Berechtigung",
"username_taken": "Der Benutzername ist bereits vergeben",
"game_already_started": "Das Spiel hat bereits begonnen",
"missing_parameter": "Fehlender Parameter",
"invalid_card_index": "Ungültige Karte ausgewählt (Index außerhalb der Grenzen)",
"card_not_playable": "Die Karte ist nicht spielbar",
"card_not_updatable": "Die Karte ist nicht aktualisierbar",
"error_message": "Fehlermeldung: {error_message}"
},
"game_screen": {
"loading": "Laden"
}
}

116
frontend/src/i18n/en.json Normal file
View File

@ -0,0 +1,116 @@
{
"page_name": "HexDeck",
"header": {
"theme_btn": {
"tooltip": "Switch theme: {current_theme}",
"dark": "Dark",
"light": "Light",
"system": "System"
}
},
"footer": {
"imprint": "Imprint",
"github": "GitHub"
},
"404": {
"404_page_not_found": "404 - Page not found",
"page_not_found": "The page {page} could not be found."
},
"imprint": {
"title": "Imprint",
"something_went_wrong": "Something went wrong",
"timeout_while_loading": "Timeout while loading",
"retry": "Retry",
"go_back": "Back"
},
"landing_page": {
"sub_title": "Multiplayer, free, for everyone",
"connect_room": {
"rejoin_last_room": "Rejoin last game",
"join_last_room": "Join last room",
"enter_room_code": "Enter a room code",
"join_room": "Join",
"enter_room_code_to_join": "Please enter a room code to join",
"or": "or",
"create_a_room": "Create a room"
},
"open_source_container": {
"title": "Open Source",
"content": "The Source Code of this game is available on GitHub",
"github": "GitHub"
},
"stats_container": {
"title": "Stats",
"online_player_count": "Current player: {count}",
"current_game_rooms": "Current games: {count}",
"games_played": "Games played: {count}",
"no_data": "No data"
},
"show_footer": "Show footer"
},
"lobby": {
"search_player": "Search player...",
"kick_player": "Kick player",
"confirm_kick_player_message": "Do you really want to kick the player {player_name}?",
"confirm_kick_player": "Kick",
"rename_yourself": "Rename yourself",
"rename_player": "Rename player",
"regenerate_join_code": "Regenerate join code",
"copy_join_code": "Copy join code",
"room_join_code": "Room Join Code",
"copy_code": "Copy Code",
"copy_join_link": "Copy Link",
"leave_game": "Leave game",
"confirm_leave_message": "Do you really want to leave the game?",
"confirm_leave": "Yes, leave",
"cancel": "Cancel",
"start_game": "Start game",
"copied": "Copied",
"player_name": "Player Name",
"status": "Status",
"host": "Host",
"you": "You",
"player": "Player",
"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",
"player_won_the_game": "{player_name} has won the game!",
"you_won_the_game": "You have won the game!",
"go_back": "Go back",
"play_again": "Play again"
},
"player_status": {
"connected": "Connected",
"disconnected": "Disconnected"
},
"game_status": {
"game_status": "Game status:",
"lobby": "Lobby",
"running": "Running",
"ended": "Ended"
},
"error_messages": {
"no_room_found": "No room was found with this code",
"request_timeout": "Internet failed! (Timeout)",
"invalid_player": "Invalid player",
"invalid_session": "Invalid session",
"game_not_running": "The game is not running",
"player_not_active": "The player ist not active",
"insufficient_permission": "Insufficient permission",
"username_taken": "The username is already taken",
"game_already_started": "The game has already started",
"missing_parameter": "Missing parameter",
"invalid_card_index": "Invalid card selected (Index not in bounds)",
"card_not_playable": "The card is not playable",
"card_not_updatable": "The card is not updatable",
"error_message": "Error message: {error_message}"
},
"game_screen": {
"loading": "Loading"
}
}

18
frontend/src/i18n/i18n.ts Normal file
View File

@ -0,0 +1,18 @@
import { addMessages, register, init, getLocaleFromNavigator } from "svelte-i18n";
import en from "./en.json";
import de from "./de.json";
addMessages("en", en);
addMessages("de", de);
register("en", () => import("./en.json"));
register("de", () => import("./de.json"));
const initialLocale = getLocaleFromNavigator();
console.log("Initial locale:", initialLocale);
init({
fallbackLocale: "en",
initialLocale: initialLocale,
});

12
frontend/src/index.css Normal file
View File

@ -0,0 +1,12 @@
@import url("https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&display=swap");
@import "tailwindcss";
@plugin 'flowbite/plugin';
@custom-variant dark {
@media not print {
.dark & {
@slot;
}
}
}
@source "../node_modules/flowbite-svelte/dist";
@import "./app.scss";

10
frontend/src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { mount } from "svelte";
import "./i18n/i18n";
import "./index.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;

View File

@ -0,0 +1,76 @@
<script lang="ts">
import EndScreen from "./../views/Game/EndScreen.svelte";
import Main from "./../views/Game/Main.svelte";
import Lobby from "../views/Game/Lobby.svelte";
import { _ } from "svelte-i18n";
import { onMount } from "svelte";
import { GameState, sessionStore } from "../stores/sessionStore";
import { Spinner } from "flowbite-svelte";
import { SvelteDate } from "svelte/reactivity";
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);
const joinParam = params.get("join");
if (joinParam) {
await requestJoinRoom(joinParam);
// Maybe show message instead redirecting to / if the join was unsuccessful
}
if (!sessionStore.hasSessionData()) {
console.warn("No sessionData found! Go back home.");
window.history.replaceState({}, "", "/");
}
sessionStore.connect();
});
</script>
{#if !$sessionStore.connected}
<div class="flex flex-row w-full mt-32 h-full justify-center items-center">
<div class="flex flex-col items-center gap-6 p-7 md:flex-row md:gap-8 rounded-2xl">
<div>
<Spinner size="12" class="text-primary-100" />
</div>
<div class="grid items-center text-center md:items-start">
<span class="text-2xl font-medium">
{$_("game_screen.loading")}
</span>
<span class="font-medium text-sky-500">
{$sessionStore.players?.find((player) => player.PlayerId == $sessionStore.userId)?.Username}
</span>
<span class="flex gap-2 font-medium text-gray-600 dark:text-gray-400">
<span>{new SvelteDate().toLocaleString()}</span>
</span>
</div>
</div>
</div>
{:else}
{#if $sessionStore.gameState == GameState.Lobby}
<div>
<!-- Lobby and player list -->
<Lobby {cardWidth} {cardHeight} {centerDistancePx} {maxRotationDeg} />
</div>
{/if}
{#if $sessionStore.gameState == GameState.Running}
<div class="size-full">
{#if $gameStore.isLobbyOverlayShown}
<div class="absolute inset-0 z-10 bg-white/30 dark:bg-black/30 backdrop-blur-sm mt-24">
<Lobby {cardWidth} {cardHeight} {centerDistancePx} {maxRotationDeg} />
</div>
{/if}
<!-- Running game -->
<Main {cardWidth} {cardHeight} {centerDistancePx} {maxRotationDeg} />
</div>
{:else if $sessionStore.gameState == GameState.Ended}
<EndScreen />
{/if}
{/if}

View File

@ -0,0 +1,18 @@
<script lang="ts">
import { Card } from "flowbite-svelte";
import { _ } from "svelte-i18n";
import { onMount } from "svelte";
import { Spinner } from "flowbite-svelte";
import { sessionStore } from "../stores/sessionStore";
onMount(async () => {
await sessionStore.leaveRoom();
});
</script>
<div class="container mx-auto p-6">
<Card class="max-w-lg mx-auto dark:text-gray-200 rounded-xl text-center space-y-2 flex">
<Spinner class="text-primary-350 dark:text-primary-200 w-12" size="4.2" />
<h1 class="text-2xl font-bold">{$_("leave.leaving_game")}</h1>
</Card>
</div>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { Button, Card } from "flowbite-svelte";
import { MoveLeft } from "lucide-svelte";
import { _ } from "svelte-i18n";
import { url } from "@roxi/routify";
function goBack() {
window.history.pushState({}, "", "/");
}
</script>
<div class="container mx-auto p-6">
<Button
color="none"
class="border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300 mb-12"
on:click={goBack}
>
<MoveLeft class="mr-2" />
<span>{$_("imprint.go_back")}</span>
</Button>
<Card class="max-w-lg mx-auto dark:text-gray-200 rounded-xl text-center space-y-2">
<h1 class="text-2xl font-bold">{$_("404.404_page_not_found")}</h1>
<h1 class="">{$_("404.page_not_found", { values: { page: $url("$leaf") } })}</h1>
</Card>
</div>

View File

@ -0,0 +1,135 @@
<script>
import { theme, toggleTheme } from "../stores/theme";
import { Gamepad2, Moon, Sun, SunMoon, UsersRound } from "lucide-svelte";
import { Tooltip, Button } from "flowbite-svelte";
import options from "../stores/pageoptions";
import { _ } from "svelte-i18n";
import gameStore, { toggleLobbyOverlay } from "../stores/gameStore";
import { GameState, sessionStore } from "../stores/sessionStore";
</script>
<header class="Header">
<div class="Header-bg"></div>
<div class="Header-content">
<div class="left-header-group header-group">
<div class="page-header-icon">
<svelte:component this={options.page_icon} size="2.4rem" />
</div>
<h1 class="text-3xl">{$_("page_name")}</h1>
</div>
<div class="middle-header-group header-group"></div>
<div class="right-header-group header-group gap-2">
<!-- Theme btn -->
<Button on:click={toggleTheme} class="!p-2 mt-2 rounded-full focus:bg-primary-700 hover:bg-primary-600 focus:ring-0" color="none">
{#if $theme === "dark"}
<Moon size="2rem" />
{:else if $theme === "light"}
<Sun size="2rem" />
{:else if $theme === "system"}
<SunMoon size="2rem" />
{/if}
</Button>
<Tooltip type="auto">
{$_("header.theme_btn.tooltip", {
values: { current_theme: $_(`header.theme_btn.${$theme}`) },
})}
</Tooltip>
<!-- Player list btn (ingame) -->
{#if $sessionStore.gameState == GameState.Running}
<Button
on:click={() => {
toggleLobbyOverlay();
}}
class="!p-2 mt-2 rounded-full focus:bg-primary-700 hover:bg-primary-600 focus:ring-0"
color="none"
>
{#if $gameStore.isLobbyOverlayShown}
<Gamepad2 size="2rem" />
{:else}
<UsersRound size="2rem" />
{/if}
</Button>
{#if $gameStore.isLobbyOverlayShown}
<Tooltip type="auto">
{$_("lobby.return_to_game")}
</Tooltip>
{:else}
<Tooltip type="auto">
{$_("lobby.player")}
</Tooltip>
{/if}
{/if}
</div>
</div>
</header>
<div class="main-container">
<div class="page-slot" style="overflow-y: auto;">
<slot />
</div>
</div>
<style>
.main-container {
display: flex;
flex-direction: column;
height: 100vh;
padding-top: 100px;
}
.page-slot {
flex-grow: 1;
overflow: hidden;
}
.Header {
background: linear-gradient(180deg, var(--default-background-color) 30%, transparent 100%);
opacity: 1;
position: fixed;
height: 100px;
top: 0;
left: 0;
width: 100%;
z-index: 30;
}
.Header-bg {
background: linear-gradient(180deg, var(--primary) 50%, transparent 100%);
z-index: -1;
margin: 0px;
position: absolute;
width: 100%;
height: 100%;
}
.Header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
}
.left-header-group,
.middle-header-group,
.right-header-group {
display: flex;
align-items: center;
}
.middle-header-group {
flex-grow: 1;
justify-content: center;
}
.right-header-group {
margin-left: auto;
}
.page-header-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
</style>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { Button, Card, Skeleton } from "flowbite-svelte";
import { MoveLeft, RefreshCcw } from "lucide-svelte";
import { _ } from "svelte-i18n";
import Markdown from "svelte-exmarkdown";
import { onMount } from "svelte";
import { goto } from "@roxi/routify";
// Reactive stores for better state management
let md: string = "";
let loading: boolean = true;
let error: string | null = null;
function goBack() {
window.history.back();
}
async function getImprintMd() {
loading = true;
error = null;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/imprint`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) throw new Error("Server error");
const data = await response.json();
if (!data?.Content) throw new Error("Empty response");
md = data.Content;
} catch (err: any) {
if (err.name === "AbortError") {
error = "imprint.timeout_while_loading";
} else {
error = "imprint.something_went_wrong";
}
} finally {
loading = false;
}
}
onMount(getImprintMd);
</script>
<div class="container mx-auto p-6">
<Button
color="none"
class="border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300 mb-12"
on:click={goBack}
>
<MoveLeft class="mr-2" />
<span>{$_("imprint.go_back")}</span>
</Button>
<Card class="max-w-lg mx-auto dark:text-gray-200 rounded-xl">
<h1 class="text-2xl font-bold mb-6">{$_("imprint.title")}</h1>
{#if loading}
<div class="w-full">
<Skeleton size="lg" />
</div>
{:else if error}
<div class="text-red-400 text-lg font-semibold grid">
{$_(error)}
<Button
color="none"
class="border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300 mt-4"
on:click={getImprintMd}
>
<RefreshCcw class="mr-2" />
<span>{$_("imprint.retry")}</span>
</Button>
</div>
{:else}
<Markdown {md} />
{/if}
</Card>
</div>

View File

@ -0,0 +1,58 @@
<script>
import Footer from "../components/Footer.svelte";
import ConnectRoom from "../components/ConnectRoom.svelte";
import StatsContainer from "../components/StatsContainer.svelte";
import { GradientButton } from "flowbite-svelte";
import { _ } from "svelte-i18n";
import options from "../stores/pageoptions";
</script>
<div class="overflow-auto size-full pb-18">
<div class="flex justify-center mb-8">
<div>
<!-- Top Title container -->
<div class="flex items-center space-x-2 my-6">
<div>
<svelte:component this={options.page_icon} size="5.2rem" />
</div>
<div>
<h2 class="text-4xl">{$_("page_name")}</h2>
<h3 class="text-xl">
{$_("landing_page.sub_title")}
</h3>
</div>
</div>
<!-- Join or create rooms -->
<ConnectRoom />
</div>
</div>
<div class="flex justify-center">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8">
<!-- Open source container -->
<div class="p-4 bg-primary-50 dark:bg-primary-950 rounded-xl grid content-start justify-items-center w-3xs text-center space-y-2 border-1 border-primary-200 dark:border-primary-800">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15.157 20.136c.211.51.8.757 1.284.492a9.25 9.25 0 1 0-8.882 0c.484.265 1.073.018 1.284-.492l1.358-3.28c.212-.51-.043-1.086-.478-1.426a3.7 3.7 0 1 1 4.554 0c-.435.34-.69.916-.478 1.426z"
/></svg
>
<h4 class="text-xl font-semibold">
{$_("landing_page.open_source_container.title")}
</h4>
<span>{$_("landing_page.open_source_container.content")}</span>
<GradientButton color="purpleToBlue" class="opacity-75 focus:opacity-100 focus:ring-0" href="https://github.com/HexCardGames/HexDeck" target="_blank">
{$_("landing_page.open_source_container.github")}
</GradientButton>
</div>
<!-- stats container -->
<StatsContainer />
</div>
</div>
</div>
<Footer />

View File

@ -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 },
];

View File

@ -0,0 +1,20 @@
import { writable } from "svelte/store";
interface GameState {
isLobbyOverlayShown: boolean;
}
const initialState: GameState = {
isLobbyOverlayShown: false,
};
const gameStore = writable<GameState>(initialState);
export const toggleLobbyOverlay = () => {
gameStore.update((state) => ({
...state,
isLobbyOverlayShown: !state.isLobbyOverlayShown,
}));
};
export default gameStore;

View File

@ -0,0 +1,7 @@
import { Gamepad2 } from "lucide-svelte";
const options = {
page_icon: Gamepad2,
};
export default options;

View File

@ -0,0 +1,146 @@
import { writable } from "svelte/store";
import { sessionStore } from "./sessionStore";
export const loading = writable<"join" | "create" | false>(false);
export const join_error = writable<string | false>(false);
export const create_error = writable<string | false>(false);
export const rejoinRoomCode = writable<string>("");
export const rejoinRoomSessionData = writable<{ sessionToken: string; userId: string }>({ sessionToken: "", userId: "" });
export async function requestJoinRoom(joinCode: string) {
loading.set("join");
join_error.set(false);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/room/join`, {
method: "POST",
body: JSON.stringify({
JoinCode: joinCode.replaceAll("-", ""),
UsernameProposal: "UsernameProposal",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const data: { StatusCode: string; Message: string } = await response.json();
if (["invalid_join_code"].includes(data?.StatusCode)) {
join_error.set("no_room_found");
} else if (data?.Message) {
join_error.set(data?.Message);
} else {
throw new Error("Server error");
}
return;
}
const data: { SessionToken: string; PlayerId: string; Username: string; Permissions: any } = await response.json();
const SessionToken = data.SessionToken;
const UserId = data.PlayerId;
joinSession(SessionToken, UserId);
} catch (error: any) {
if (error.name === "AbortError") {
join_error.set("timeout");
} else {
join_error.set("request_failed");
}
console.error("Error joining room: ", error);
} finally {
loading.set(false);
}
}
export async function requestCreateRoom() {
loading.set("create");
create_error.set(false);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/room/create`, {
method: "POST",
body: JSON.stringify({
UsernameProposal: "UsernameProposal",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error("Server error");
}
const data: { SessionToken: string; PlayerId: string; Username: string; Permissions: any } = await response.json();
const SessionToken = data.SessionToken;
const UserId = data.PlayerId;
sessionStore.connect(SessionToken, UserId);
} catch (error: any) {
if (error.name === "AbortError") {
create_error.set("timeout");
} else {
create_error.set(String(error));
}
console.error("Error creating room:", error);
} finally {
loading.set(false);
}
}
export function joinSession(sessionToken: string, userId: string) {
try {
sessionStore.connect(sessionToken, userId);
} catch (error: any) {
join_error.set("request_failed");
console.error("Error joining room session: ", error);
} finally {
loading.set(false);
}
}
export async function checkSessionToken(sessionToken: string | undefined): Promise<boolean> {
if (!sessionToken) return false;
const params = new URLSearchParams({ sessionToken: sessionToken });
const res = await fetch(`/api/check/session?${params}`);
return res.status == 200;
}
export async function checkJoinCode(joinCode: string | undefined): Promise<boolean> {
if (!joinCode) return false;
const params = new URLSearchParams({ JoinCode: joinCode });
const res = await fetch(`/api/check/joinCode?${params}`);
return res.status == 200;
}
export async function checkSessionData() {
const currentSessionData: { sessionToken?: string; userId?: string; joinCode?: string } = JSON.parse(localStorage.getItem("currentSessionIds") || "{}");
if (await checkSessionToken(currentSessionData.sessionToken)) {
rejoinRoomSessionData.set(currentSessionData as any);
return;
}
if (await checkJoinCode(currentSessionData.joinCode)) {
rejoinRoomCode.set(currentSessionData.joinCode as string);
return;
}
const lastSessionData: { sessionToken?: string; userId?: string; joinCode?: string } = JSON.parse(localStorage.getItem("lastSessionIds") || "{}");
if (await checkSessionToken(lastSessionData.sessionToken)) {
rejoinRoomSessionData.set(lastSessionData as any);
return;
}
if (await checkJoinCode(lastSessionData.joinCode)) {
rejoinRoomCode.set(lastSessionData.joinCode as string);
return;
}
}

View File

@ -0,0 +1,439 @@
import { writable, get, derived } from "svelte/store";
import { io, Socket } from "socket.io-client";
export enum GameState {
Undefined = -1,
Lobby,
Running,
Ended,
}
interface PlayerPermissionObj {
isHost: boolean;
}
interface GameOptions {}
export interface PlayerObj {
PlayerId: string;
Username: string;
Permissions: number;
IsConnected: boolean;
}
interface SessionData {
roomId: string | null;
joinCode: string | null;
gameOptions: GameOptions;
players: Array<PlayerObj>;
cardDeckId: number | null;
gameState: GameState;
socket: Socket | null;
connected: boolean;
userId: string | null;
messages: any[];
sessionToken: string | null;
playedCards: CardPlayedObj[];
ownCards: CardInfoObj[];
playerStates: { [key: string]: PlayerStateObj };
winner: string | undefined;
}
interface RoomInfoObj {
RoomId: string;
JoinCode: string;
TopCard: Card;
GameState: GameState;
CardDeckId: number;
Winner?: string;
Players: PlayerObj[];
}
interface StatusInfoObj {
IsError: boolean;
StatusCode: string;
Message: string;
}
export interface CardInfoObj {
CanPlay: boolean;
Card: Card;
}
interface OwnCardsObj {
Cards: CardInfoObj[];
}
export interface PlayerStateObj {
PlayerId: string;
NumCards: number;
Active: boolean;
}
export interface CardPlayedObj {
Card: Card;
CardIndex: number;
PlayedBy: string;
}
interface PlayedCardUpdateObj {
UpdatedBy: string;
Card: Card;
}
interface SetCardDeckReq {
CardDeckId: number;
}
interface PlayCardReq {
CardIndex?: number;
CardData: any;
}
interface UpdatePlayedCardReq {
CardData: any;
}
export type Card = any;
class SessionManager {
store = writable<SessionData>({
roomId: null,
joinCode: null,
gameState: -1,
gameOptions: {},
players: [],
cardDeckId: null,
socket: null,
connected: false,
userId: null,
messages: [],
sessionToken: null,
playedCards: [],
ownCards: [],
playerStates: {},
winner: undefined,
});
private socket: Socket | null = null;
constructor() {
const storedSessionIds = this.getStoredSessionIds();
if (storedSessionIds) {
console.info(`Found stored session: ${JSON.stringify(storedSessionIds)}`);
// this.connect(storedSessionIds.sessionToken, storedSessionIds.userId);
}
}
getState() {
return get(this.store);
}
startGame() {
this.socket?.emit("StartGame");
}
hasSessionData(): boolean {
const state = this.getState();
if (state.sessionToken && state.userId) return true;
const sessionIds = localStorage.getItem("currentSessionIds");
if (!sessionIds) return false;
const sessionIdsJson = JSON.parse(sessionIds);
return typeof sessionIdsJson.userId === "string" && typeof sessionIdsJson.sessionToken === "string";
}
private checkPermissionBit(permissionNumber: number, bitIndex: number): boolean {
return (permissionNumber & (1 << bitIndex)) > 0;
}
getPlayerPermissions(PlayerId?: string): PlayerPermissionObj {
if (!PlayerId) PlayerId = this.getState().userId ?? undefined;
const playerPermissionNumber: number = this.getState().players?.find((player) => player.PlayerId == PlayerId)?.Permissions ?? 0;
return {
isHost: this.checkPermissionBit(playerPermissionNumber, 0),
};
}
subscribe = this.store.subscribe;
private getStoredSessionIds(): { sessionToken: string; userId: string } | null {
if (typeof window === "undefined") return null;
const sessionIds = localStorage.getItem("currentSessionIds");
if (!sessionIds) return null;
const sessionIdsJson = JSON.parse(sessionIds);
if (typeof sessionIdsJson.userId !== "string" || typeof sessionIdsJson.sessionToken !== "string") {
return null;
}
return { sessionToken: sessionIdsJson.sessionToken, userId: sessionIdsJson.userId };
}
private saveSessionIds(sessionToken: string, userId: string) {
if (typeof window !== "undefined") {
localStorage.setItem("currentSessionIds", JSON.stringify({ sessionToken, userId, joinCode: this.getState().joinCode }));
}
}
private saveJoinCode() {
const sessionIds = localStorage.getItem("currentSessionIds");
if (!sessionIds) return;
const sessionIdsJson = JSON.parse(sessionIds);
localStorage.setItem("currentSessionIds", JSON.stringify({ sessionToken: sessionIdsJson.sessionToken, userId: sessionIdsJson.userId, joinCode: this.getState().joinCode }));
}
private clearSessionIds() {
if (typeof window !== "undefined") {
const sessionIds = localStorage.getItem("currentSessionIds");
if (!sessionIds) return;
const sessionIdsJson = JSON.parse(sessionIds);
const lastSessionData = { joinCode: sessionIdsJson.joinCode };
localStorage.setItem("lastSessionIds", JSON.stringify(lastSessionData));
localStorage.removeItem("currentSessionIds");
}
}
isConnected(): boolean {
return this.socket?.connected ?? false;
}
hasRoomData(): boolean {
return get(this.store).gameState != -1;
}
getUserId(): string | undefined {
return this.getState().userId ?? undefined;
}
getUser(playerId?: string): PlayerObj | undefined {
if (!playerId) playerId = this.getUserId();
return this.getState().players.find((player) => player.PlayerId == playerId);
}
kickPlayer(playerId: string) {
if (!this.getPlayerPermissions().isHost) return;
this.socket?.emit("KickPlayer", JSON.stringify({ PlayerId: playerId }));
}
renamePlayer(playerId: string | undefined, newName: string) {
if (!playerId) playerId = this.getUserId();
if (!this.getPlayerPermissions().isHost && playerId != this.getUserId()) return;
this.socket?.emit("UpdatePlayer", JSON.stringify({ PlayerId: playerId, Username: newName }));
}
isCurrentPlayer(playerId: string): boolean {
return this.getState().userId == playerId;
}
connect(sessionToken?: string, userId?: string) {
if (!sessionToken) sessionToken = this.getState().sessionToken || undefined;
if (!userId) userId = this.getState().userId || undefined;
if (!sessionToken || !userId) {
const storedSessionIds = this.getStoredSessionIds();
if (!sessionToken) sessionToken = storedSessionIds?.sessionToken;
if (!userId) userId = storedSessionIds?.userId;
}
if (!sessionToken || !userId) {
console.warn("Socket connection requested without sessionToken or userId");
return;
}
if (this.socket) {
console.warn(`Socket already connected! Rejecting new connection to ${sessionToken}`);
return;
}
this.socket = io({
transports: ["websocket"],
query: { sessionToken },
});
this.setupSocketEventHandlers(sessionToken, userId);
}
private setupSocketEventHandlers(sessionToken: string, userId: string) {
this.socket?.on("connect", () => this.handleConnect(sessionToken, userId));
this.socket?.on("disconnect", this.handleDisconnect.bind(this));
this.socket?.on("Status", this.handleStatus.bind(this));
this.socket?.on("RoomInfo", this.handleRoomInfo.bind(this));
this.socket?.on("OwnCards", this.handleOwnCardsUpdate.bind(this));
this.socket?.on("PlayerState", this.handlePlayerStateUpdate.bind(this));
this.socket?.on("CardPlayed", this.handleCardPlayed.bind(this));
this.socket?.on("PlayedCardUpdate", this.handlePlayedCardUpdate.bind(this));
this.socket?.on("error", this.handleError.bind(this));
}
private handleConnect(sessionToken: string, userId: string) {
console.info("Connected to room");
this.saveSessionIds(sessionToken, userId);
window.history.replaceState({}, "", "/Game");
this.store.update((state) => ({
...state,
socket: this.socket,
userId,
connected: true,
sessionToken,
}));
}
private handleDisconnect() {
console.info("Disconnected from server");
this.store.update((state) => ({ ...state, connected: false }));
}
private handleStatus(message: StatusInfoObj) {
console.log("Status: ", message);
if (message.StatusCode == "connection_from_different_socket") {
this.socket = null;
window.history.replaceState({}, "", "/");
}
if (message.IsError) {
console.warn("Received error from server: ", message);
}
this.store.update((state) => ({
...state,
messages: [...state.messages, message],
}));
}
private handleRoomInfo(message: RoomInfoObj) {
console.log("RoomInfo: ", message);
this.store.update((state) => ({
...state,
roomId: message.RoomId,
joinCode: message.JoinCode,
gameState: message.GameState,
cardDeckId: message.CardDeckId,
players: message.Players,
winner: message.Winner,
}));
if (message.TopCard && get(this.store).playedCards.length == 0) {
this.store.update((state) => ({
...state,
playedCards: [{ Card: message.TopCard, CardIndex: -1, PlayedBy: "" }],
}));
}
this.saveJoinCode();
this.store.update((state) => ({
...state,
messages: [...state.messages, message],
}));
}
private handleOwnCardsUpdate(message: OwnCardsObj) {
this.store.update((state) => ({
...state,
ownCards: message.Cards,
}));
}
private handlePlayerStateUpdate(message: PlayerStateObj) {
get(this.store).playerStates[message.PlayerId] = message;
this.store.update((state) => state);
}
private handleCardPlayed(message: CardPlayedObj) {
this.store.update((state) => ({
...state,
playedCards: [...state.playedCards, message],
}));
}
private handlePlayedCardUpdate(message: PlayedCardUpdateObj) {
if (get(this.store).playedCards.length == 0) {
return;
}
get(this.store).playedCards[get(this.store).playedCards.length - 1].Card = message.Card;
this.store.update((state) => state);
}
private handleError(error: string) {
console.error("Socket error:", error);
}
get players(): PlayerObj[] {
return get(this.store).players;
}
get ownCards(): Card[] {
return get(this.store).ownCards;
}
get playedCards(): CardPlayedObj[] {
return get(this.store).playedCards;
}
getPlayerState(playerId: string): PlayerStateObj | undefined {
return get(this.store).playerStates[playerId];
}
sendMessage(event: string, message: string) {
if (this.socket) {
this.socket.emit(event, message);
}
}
setCardDeck(id: number) {
let request: SetCardDeckReq = {
CardDeckId: id,
};
this.sendMessage("SetCardDeck", JSON.stringify(request));
}
drawCard() {
this.sendMessage("DrawCard", "");
}
playCard(cardIndex: number, data?: any) {
let request: PlayCardReq = {
CardIndex: cardIndex,
CardData: data,
};
this.sendMessage("PlayCard", JSON.stringify(request));
}
updatePlayedCard(data?: any) {
let request: UpdatePlayedCardReq = {
CardData: data,
};
this.sendMessage("UpdatePlayedCard", JSON.stringify(request));
}
leaveRoom() {
console.log("leave room");
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
if (this.getState().sessionToken) {
fetch(`/api/room/leave`, {
method: "POST",
body: JSON.stringify({
SessionToken: this.getState().sessionToken,
}),
headers: {
"Content-Type": "application/json",
},
});
}
this.clearSessionIds();
this.store.set({
roomId: null,
joinCode: null,
gameState: -1,
gameOptions: {},
players: [],
cardDeckId: null,
socket: null,
connected: false,
userId: null,
messages: [],
sessionToken: null,
playedCards: [],
ownCards: [],
playerStates: {},
winner: undefined,
});
window.history.replaceState({}, "", "/");
}
}
export const sessionStore = new SessionManager();

View File

@ -0,0 +1,43 @@
import { writable } from "svelte/store";
const getSystemTheme = () => (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const storedTheme = localStorage.getItem("theme") as Theme | null;
const initialTheme: Theme = storedTheme === "dark" || storedTheme === "light" ? storedTheme : "system";
type Theme = "dark" | "light" | "system";
export const theme = writable<Theme>(initialTheme);
const applyTheme = (value: Theme) => {
const resolvedTheme = value === "system" ? getSystemTheme() : value;
document.documentElement.classList.toggle("dark", resolvedTheme === "dark");
document.documentElement.setAttribute("data-theme", resolvedTheme);
document.body.classList.toggle("dark-theme", resolvedTheme === "dark");
document.body.classList.toggle("light-theme", resolvedTheme === "light");
localStorage.setItem("theme", value);
};
theme.subscribe(applyTheme);
// Watch for system theme changes when "system" mode is enabled
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
theme.update((current) => {
if (current === "system") applyTheme("system");
return current;
});
});
export const setTheme = (value: Theme) => {
theme.set(value);
};
export const toggleTheme = () => {
theme.update((current) => {
if (current === "dark") return "light";
if (current === "light") return "system";
return "dark";
});
};

View File

@ -0,0 +1,37 @@
<script>
import { CrownIcon, TimerIcon } from "lucide-svelte";
import { _ } from "svelte-i18n";
import { sessionStore } from "../../stores/sessionStore";
import { Button } from "flowbite-svelte";
</script>
<div class="flex size-full justify-center items-center">
<div class="flex flex-col items-center gap-2">
{#if $sessionStore.winner == undefined}
<TimerIcon size={64} />
<span>{$_("end_screen.game_has_ended")}</span>
{:else}
<CrownIcon size={64} />
{#if $sessionStore.winner == sessionStore.getUserId()}
<span>{$_("end_screen.you_won_the_game")}</span>
{:else}
<span
>{$_("end_screen.player_won_the_game", {
values: { player_name: sessionStore.getUser($sessionStore.winner)?.Username },
})}</span
>
{/if}
{/if}
<div class="flex flex-row gap-3 mt-5">
<Button
color="alternative"
on:click={() => {
sessionStore.leaveRoom();
window.history.replaceState({}, "/Game", "/");
}}>{$_("end_screen.go_back")}</Button
>
<!-- TODO: implement rematch with the same players -->
<Button>{$_("end_screen.play_again")}</Button>
</div>
</div>
</div>

View File

@ -0,0 +1,324 @@
<script lang="ts">
import RenamePlayer from "../../components/RenamePlayer.svelte";
import CardDeckShowcase from "../../components/CardDeckShowcase.svelte";
import { Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell, TableSearch, Badge, Button, Modal, Popover, Tooltip, Card } from "flowbite-svelte";
import { CircleArrowOutUpLeft, Copy, AlertCircle, UserX, Play, TextCursorInput, Gamepad2 } from "lucide-svelte";
import { _ } from "svelte-i18n";
import { GameState, sessionStore } from "../../stores/sessionStore";
import { toggleLobbyOverlay } from "../../stores/gameStore";
import { CardDecks } from "../../stores/cardDeck";
let copied = false;
let showLeaveModal = false;
$: players = $sessionStore.players;
let searchQuery = "";
let rename_player = "";
let showRenameModal = false;
let kick_player = "";
let showKickModal = false;
let showCardDeckModal = false;
export let maxRotationDeg: number;
export let centerDistancePx: number;
export let cardWidth: number;
export let cardHeight: number;
function filteredPlayers() {
return players.filter((player) => player.Username.toLowerCase().includes(searchQuery.toLowerCase()));
}
function insert(str: string, index: number, value: string) {
return str.slice(0, index) + value + str.slice(index);
}
function copyGameCodeToClipboard() {
navigator.clipboard.writeText(insert($sessionStore.joinCode || "000000", 3, "-")).then(() => {
copied = true;
setTimeout(() => (copied = false), 2000);
});
}
function copyGameLinkToClipboard() {
navigator.clipboard.writeText(`${window.location.origin}/Game?join=${$sessionStore.joinCode}`).then(() => {
copied = true;
setTimeout(() => (copied = false), 2000);
});
}
function leaveRoom() {
sessionStore.leaveRoom();
showLeaveModal = false;
}
</script>
<!-- Modal: Confirm Leave Room -->
<Modal bind:open={showLeaveModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" autoclose outsideclose>
<div class="text-center">
<AlertCircle class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
{$_("lobby.confirm_leave_message")}
</h3>
<Button on:click={() => (showLeaveModal = false)} color="alternative" class="hover:text-dark hover:bg-gray-100">{$_("lobby.cancel")}</Button>
<Button on:click={leaveRoom} color="red" class="me-2">{$_("lobby.confirm_leave")}</Button>
</div>
</Modal>
<!-- Modal: Rename Player -->
<Modal bind:open={showRenameModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" autoclose outsideclose>
<div class="text-center">
<TextCursorInput class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
<RenamePlayer playerId={rename_player} />
</div>
</Modal>
<!-- Modal: Confirm Kick Player -->
<Modal bind:open={showKickModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" autoclose outsideclose>
<div class="text-center">
<UserX class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
{$_("lobby.confirm_kick_player_message", {
values: { player_name: sessionStore.getUser(kick_player)?.Username || "Name not found" },
})}
</h3>
<Button on:click={() => (showLeaveModal = false)} color="alternative" class="hover:text-dark hover:bg-gray-100">{$_("lobby.cancel")}</Button>
<Button
on:click={() => {
sessionStore.kickPlayer(kick_player);
}}
color="red"
class="me-2">{$_("lobby.confirm_kick_player")}</Button
>
</div>
</Modal>
<!-- Modal: Select card deck -->
<Modal bind:open={showCardDeckModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" style="color: unset;" autoclose outsideclose>
<div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
{$_("lobby.card_deck_modal")}
</h3>
<div class="grid justify-center gap-5">
{#each CardDecks as cardDeck}
<Card style="color: unset;">
<span class="mb-[-15px] text-xl">{cardDeck.name}</span>
<div class="flex justify-center">
<CardDeckShowcase cardComponent={cardDeck.cardComponent} cardDeckId={cardDeck.id} {cardHeight} {cardWidth} centerDistancePx={centerDistancePx * 3} {maxRotationDeg} />
</div>
<Button
on:click={() => {
sessionStore.setCardDeck(cardDeck.id);
}}>{$_("lobby.choose_card_deck")}</Button
>
</Card>
{/each}
</div>
<Button on:click={() => (showCardDeckModal = false)} color="alternative" class="mt-5 hover:text-dark hover:bg-gray-100">{$_("lobby.cancel")}</Button>
</div>
</Modal>
<div class="flex">
<!-- Leave Room Button -->
<Button
color="none"
class="m-2 border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300"
on:click={() => {
showLeaveModal = true;
}}
>
<CircleArrowOutUpLeft class="mr-2" />
<span>{$_("lobby.leave_game")}</span>
</Button>
<!-- Return to game Button -->
{#if sessionStore.getState().gameState !== GameState.Lobby}
<Button
color="none"
class="m-2 ml-auto border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300"
on:click={() => {
toggleLobbyOverlay();
}}
>
<span>{$_("lobby.return_to_game")}</span>
<Gamepad2 class="ml-2" />
</Button>
{/if}
</div>
<!-- Game Status -->
<div class="md:mt-[-65px] text-center p-6 w-full">
<span
>{$_("game_status.game_status")}
<Badge color="dark">
{$_(`game_status.${GameState[$sessionStore.gameState].toLowerCase()}`)}
</Badge>
</span>
</div>
<div class="grid lg:grid-flow-col grid-flow-row justify-center mt-6 mb-2 gap-4">
<!-- Rename (This) Player -->
{#if sessionStore.isConnected()}
<RenamePlayer />
{/if}
<!-- Copy Join Code Button -->
<!-- TODO add Streamer mode (hide room code) here -->
{#if sessionStore.getState().gameState === GameState.Lobby}
<Button
id="b1"
type="button"
class="w-xs mx-auto text-dark bg-primary-200 dark:bg-primary-900 hover:bg-primary-200 dark:hover:bg-primary-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
on:click={() => {
copyGameCodeToClipboard();
}}
>
<div class="grid justify-items-start">
<span class="text-sm">{$_("lobby.room_join_code")}</span>
<div class="relative">
<span class="text-xl font-semibold tracking-widest select-none transition-opacity duration-300 opacity-0" class:opacity-100={!copied}
>{insert($sessionStore.joinCode || "000000", 3, "-")}
</span>
<span class="absolute left-0 text-xl font-semibold tracking-widest select-none transition-opacity duration-300 opacity-0" class:opacity-100={copied}>
{$_("lobby.copied")}
</span>
</div>
</div>
<Copy />
</Button>
<Popover class="text-sm max-w-screen font-light z-100" triggeredBy="#b1" placement="bottom">
<div class="grid gap-2">
<Button
on:click={() => {
copyGameCodeToClipboard();
}}
>
{$_("lobby.copy_code")}
</Button>
<Button
on:click={() => {
copyGameLinkToClipboard();
}}
>
{$_("lobby.copy_join_link")}
</Button>
{#if sessionStore.getPlayerPermissions().isHost}
<Button on:click={() => {}}>
{$_("lobby.regenerate_join_code")}
</Button>
{/if}
</div>
</Popover>
{/if}
<!-- Start game button -->
{#if sessionStore.getPlayerPermissions().isHost && sessionStore.getState().gameState === GameState.Lobby}
<Button
class="w-xs mx-auto text-dark bg-green-200 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
on:click={() => {
sessionStore.startGame();
}}
>
<div class="grid justify-items-start">
<div class="relative">
<span class="text-xl font-semibold tracking-widest select-none transition-opacity duration-300">{$_("lobby.start_game")} </span>
</div>
</div>
<Play />
</Button>
{/if}
</div>
{#if players.length > 5}
<!-- Search Bar -->
<TableSearch bind:inputValue={searchQuery} placeholder={$_("lobby.search_player")} />
{/if}
<!-- Card deck selection -->
{#if sessionStore.getState().gameState == GameState.Lobby}
<div class="flex w-full justify-center my-10">
<Card style="color: unset;">
<span class="text-xl">{$_("lobby.selected_card_deck")}</span>
<span class="opacity-80 mb-[-15px]">{CardDecks.find((e) => e.id == $sessionStore.cardDeckId)?.name}</span>
<div class="flex justify-center">
<CardDeckShowcase
cardComponent={CardDecks.find((e) => e.id == $sessionStore.cardDeckId)?.cardComponent}
cardDeckId={CardDecks.find((e) => e.id == $sessionStore.cardDeckId)?.id ?? 0}
{cardHeight}
{cardWidth}
centerDistancePx={centerDistancePx * 3}
{maxRotationDeg}
/>
</div>
{#if sessionStore.getPlayerPermissions().isHost}
<Button on:click={() => (showCardDeckModal = true)}>{$_("lobby.change_card_deck")}</Button>
{/if}
</Card>
</div>
{/if}
<!-- Players Table -->
<Table striped hoverable noborder class="mb-16">
<TableHead>
<TableHeadCell class="cursor-pointer flex items-center">
{$_("lobby.player_name")}
</TableHeadCell>
<TableHeadCell>{$_("lobby.status")}</TableHeadCell>
</TableHead>
<TableBody tableBodyClass="divide-y">
{#each filteredPlayers() as player}
<TableBodyRow class="!bg-black/2 hover:!bg-black/4 dark:!bg-white/20 dark:hover:!bg-white/30">
<TableBodyCell>
{player.Username}
{#if sessionStore.isCurrentPlayer(player.PlayerId)}
<Badge color="purple" class="ml-1">{$_("lobby.you")}</Badge>
{/if}
{#if sessionStore.getPlayerPermissions(player.PlayerId).isHost}
<Badge color="blue" class="ml-1">{$_("lobby.host")}</Badge>
{/if}
</TableBodyCell>
<TableBodyCell>
{#if player.IsConnected}
<Badge color="green">{$_(`player_status.connected`)}</Badge>
{:else}
<Badge color="yellow">{$_(`player_status.disconnected`)}</Badge>
{/if}
</TableBodyCell>
<!-- Can kick and rename player -->
{#if sessionStore.getPlayerPermissions().isHost}
<TableBodyCell>
<!-- kick player -->
<Button
outline={true}
color="alternative"
class="p-2! text-red-800 hover:bg-red-500"
size="lg"
on:click={() => {
showKickModal = true;
kick_player = player.PlayerId;
}}
>
<UserX class="w-7 h-7" />
</Button>
<Tooltip type="auto">{$_("lobby.kick_player")}</Tooltip>
<!-- rename player -->
<Button
outline={true}
color="alternative"
class="p-2! text-blue-800 hover:bg-blue-500"
size="lg"
on:click={() => {
showRenameModal = true;
rename_player = player.PlayerId;
}}
>
<TextCursorInput class="w-7 h-7" />
</Button>
<Tooltip type="auto">{$_("lobby.rename_player")}</Tooltip>
</TableBodyCell>
{/if}
</TableBodyRow>
{/each}
</TableBody>
</Table>

View File

@ -0,0 +1,80 @@
<script lang="ts">
import type { PlayerObj } from "../../stores/sessionStore";
import { derived, get } from "svelte/store";
import CardDisplay from "../../components/Game/CardDisplay.svelte";
import { sessionStore } from "../../stores/sessionStore";
import OpponentDisplay from "../../components/Game/OpponentDisplay.svelte";
import { CardDecks } from "../../stores/cardDeck";
export let maxRotationDeg: number;
export let centerDistancePx: number;
export let cardWidth: number;
export let cardHeight: number;
let cardComponent = CardDecks.find((e) => e.id == $sessionStore.cardDeckId)?.cardComponent;
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);
let perSide = 0;
$: perSide = Math.ceil($opponents.length / 3);
</script>
{#snippet OpponentCards(players: PlayerObj[], rotationDeg: number)}
{#each players as player}
<OpponentDisplay {cardComponent} {cardHeight} {cardWidth} {rotationDeg} {player} state={sessionStore.getPlayerState(player.PlayerId)} {centerDistancePx} {maxRotationDeg} />
{/each}
{/snippet}
<div class="game relative grid h-full grid-rows-[1fr_2fr_1fr]">
<div class="top flex justify-center items-center flex-row gap-20">{@render OpponentCards($opponents.slice(0, perSide), 0)}</div>
<div class="middle grid grid-cols-[1fr_300px_1fr]">
<div class="left flex justify-center items-center flex-col gap-20">
{@render OpponentCards($opponents.slice(perSide, perSide * 2), -90)}
</div>
<div class="center grid grid-cols-2">
<div class="drawCard flex justify-center items-center">
<button
class="draw"
on:click={() => {
if (get(playerActive)) sessionStore.drawCard();
}}
>
<svelte:component this={cardComponent} width={cardWidth} height={cardHeight} data={{ Color: "black", Symbol: "special:draw" }} />
</button>
</div>
<div class="cardStack flex justify-center items-center">
<CardDisplay
{cardComponent}
{cardHeight}
{cardWidth}
canPlayCards={false}
canUpdateCards={true}
updateCard={(_, data) => {
if (get(playerActive)) sessionStore.updatePlayedCard(data);
}}
cards={[{ Card: undefined, PlayedBy: "", CardIndex: -1 }, ...$sessionStore.playedCards]}
centerDistancePx={0}
maxRotationDeg={10}
/>
</div>
</div>
<div class="right flex justify-center items-center flex-col gap-20">
{@render OpponentCards($opponents.slice(perSide * 2, perSide * 3), 90)}
</div>
</div>
<div class="cardContainer w-full overflow-y-clip overflow-x-auto">
<div class="ownCards w-full min-w-min box-content flex items-end justify-center" class:opacity-60={!$playerActive}>
<CardDisplay
{cardHeight}
{cardWidth}
{cardComponent}
fullwidth
click={(i) => {
if (get(playerActive)) sessionStore.playCard(i);
}}
centerDistancePx={centerDistancePx * 3}
{maxRotationDeg}
cards={$sessionStore.ownCards}
/>
</div>
</div>
</div>

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@ -0,0 +1,7 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
};

View File

@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"exclude": [".routify"]
}

4
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

30
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,30 @@
import { defineConfig, loadEnv } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import routify from "@roxi/routify/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
const env = loadEnv(process.env.NODE_ENV as string, process.cwd(), "VITE_");
export default defineConfig({
plugins: [
routify({
/* config */
}),
tailwindcss(),
svelte(),
],
server: {
host: true,
proxy: {
"/api": {
target: env.VITE_BACKEND_URL || "http://localhost:3000",
changeOrigin: true,
},
"/socket.io": {
target: env.VITE_BACKEND_URL || "http://localhost:3000",
ws: true,
changeOrigin: true,
},
},
},
});