mirror of
https://github.com/HexCardGames/HexDeck.git
synced 2025-09-03 10:38:40 +02:00
Compare commits
36 Commits
e91fc6ce93
...
bdc8a151f4
Author | SHA1 | Date | |
---|---|---|---|
bdc8a151f4 | |||
0d3b42193f | |||
6349d5055f | |||
338710d762 | |||
2cfcd0089f | |||
4104b01978 | |||
7bbe33725f | |||
37c47a7b72 | |||
c8b9a32f25 | |||
3539cb6922 | |||
5e8890d823 | |||
c2e895d94a | |||
0b41a5e795 | |||
a54eb51e9a | |||
4b13b9fc95 | |||
5c83cd6ce2 | |||
134b89118f | |||
bbf83ef811 | |||
9088916f92 | |||
ee98d92f52 | |||
1597fb9b31 | |||
14aeb21772 | |||
ba44508f00 | |||
889ee4ce4f | |||
49e84eaac7 | |||
a4a26f04d4 | |||
d4f194b146 | |||
5f1404c7d7 | |||
540c70216c | |||
4f96206b8a | |||
8fe9519afc | |||
e5f8876464 | |||
76e657b5ec | |||
e8e36e6674 | |||
c4b9f9287f | |||
3217b2c4c3 |
3
.env.sample
Normal file
3
.env.sample
Normal 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
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# IDEs and editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# MongoDB docker volume
|
||||
data/
|
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
1
backend/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
HexDeck
|
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
HexDeck
|
138
backend/api/api.go
Normal file
138
backend/api/api.go
Normal 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
26
backend/api/static.go
Normal 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
324
backend/api/websocket.go
Normal 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
91
backend/db/db.go
Normal 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
|
||||
}
|
72
backend/db/serializable_types.go
Normal file
72
backend/db/serializable_types.go
Normal 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
197
backend/decks/classic.go
Normal 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
39
backend/decks/decks.go
Normal 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
205
backend/decks/hexv1.go
Normal 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
241
backend/game/game.go
Normal 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
66
backend/go.mod
Normal 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
189
backend/go.sum
Normal 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
63
backend/main.go
Normal 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
7
backend/public/README.md
Normal 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
146
backend/types/types.go
Normal 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
|
||||
}
|
120
backend/types/websocket_packets.go
Normal file
120
backend/types/websocket_packets.go
Normal 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
41
backend/utils/utils.go
Normal 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
8
docker-compose.dev.yml
Normal 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
16
docker-compose.yml
Normal 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
26
frontend/.dockerignore
Normal 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
2
frontend/.env.sample
Normal 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
26
frontend/.gitignore
vendored
Normal 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
10
frontend/.prettierrc.json
Normal 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
47
frontend/README.md
Normal 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
12
frontend/index.html
Normal 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
5112
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal 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
3380
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/hexagon.svg
Normal file
1
frontend/public/hexagon.svg
Normal 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
14
frontend/src/App.svelte
Normal 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
90
frontend/src/app.scss
Normal 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;
|
||||
}
|
29
frontend/src/components/CardDeckShowcase.svelte
Normal file
29
frontend/src/components/CardDeckShowcase.svelte
Normal 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} />
|
109
frontend/src/components/Cards/ClassicCard.svelte
Normal file
109
frontend/src/components/Cards/ClassicCard.svelte
Normal 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>
|
110
frontend/src/components/Cards/HexV1Card.svelte
Normal file
110
frontend/src/components/Cards/HexV1Card.svelte
Normal 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>
|
172
frontend/src/components/ConnectRoom.svelte
Normal file
172
frontend/src/components/ConnectRoom.svelte
Normal 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>
|
29
frontend/src/components/Footer.svelte
Normal file
29
frontend/src/components/Footer.svelte
Normal 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>
|
91
frontend/src/components/Game/CardDisplay.svelte
Normal file
91
frontend/src/components/Game/CardDisplay.svelte
Normal 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>
|
37
frontend/src/components/Game/OpponentDisplay.svelte
Normal file
37
frontend/src/components/Game/OpponentDisplay.svelte
Normal 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>
|
81
frontend/src/components/RenamePlayer.svelte
Normal file
81
frontend/src/components/RenamePlayer.svelte
Normal 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>
|
44
frontend/src/components/StatsContainer.svelte
Normal file
44
frontend/src/components/StatsContainer.svelte
Normal 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>
|
16
frontend/src/components/meta-title.svelte
Normal file
16
frontend/src/components/meta-title.svelte
Normal 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
116
frontend/src/i18n/de.json
Normal 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
116
frontend/src/i18n/en.json
Normal 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
18
frontend/src/i18n/i18n.ts
Normal 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
12
frontend/src/index.css
Normal 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
10
frontend/src/main.ts
Normal 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;
|
76
frontend/src/routes/Game.svelte
Normal file
76
frontend/src/routes/Game.svelte
Normal 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}
|
18
frontend/src/routes/Leave.svelte
Normal file
18
frontend/src/routes/Leave.svelte
Normal 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>
|
26
frontend/src/routes/[...404].svelte
Normal file
26
frontend/src/routes/[...404].svelte
Normal 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>
|
135
frontend/src/routes/_module.svelte
Normal file
135
frontend/src/routes/_module.svelte
Normal 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>
|
90
frontend/src/routes/imprint.svelte
Normal file
90
frontend/src/routes/imprint.svelte
Normal 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>
|
58
frontend/src/routes/index.svelte
Normal file
58
frontend/src/routes/index.svelte
Normal 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 />
|
13
frontend/src/stores/cardDeck.ts
Normal file
13
frontend/src/stores/cardDeck.ts
Normal 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 },
|
||||
];
|
20
frontend/src/stores/gameStore.ts
Normal file
20
frontend/src/stores/gameStore.ts
Normal 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;
|
7
frontend/src/stores/pageoptions.ts
Normal file
7
frontend/src/stores/pageoptions.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Gamepad2 } from "lucide-svelte";
|
||||
|
||||
const options = {
|
||||
page_icon: Gamepad2,
|
||||
};
|
||||
|
||||
export default options;
|
146
frontend/src/stores/roomStore.ts
Normal file
146
frontend/src/stores/roomStore.ts
Normal 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;
|
||||
}
|
||||
}
|
439
frontend/src/stores/sessionStore.ts
Normal file
439
frontend/src/stores/sessionStore.ts
Normal 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();
|
43
frontend/src/stores/theme.ts
Normal file
43
frontend/src/stores/theme.ts
Normal 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";
|
||||
});
|
||||
};
|
37
frontend/src/views/Game/EndScreen.svelte
Normal file
37
frontend/src/views/Game/EndScreen.svelte
Normal 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>
|
324
frontend/src/views/Game/Lobby.svelte
Normal file
324
frontend/src/views/Game/Lobby.svelte
Normal 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>
|
80
frontend/src/views/Game/Main.svelte
Normal file
80
frontend/src/views/Game/Main.svelte
Normal 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
2
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
7
frontend/svelte.config.js
Normal file
7
frontend/svelte.config.js
Normal 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(),
|
||||
};
|
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal 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
4
frontend/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
30
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user