WIP: Add multiplayer Snake game
N-player (2-8) real-time Snake game alongside Connect 4. Lobby has tabs to switch between games. Players join via invite link with 10-second countdown. Game loop runs at tick-based intervals with NATS pub/sub for state sync. Keyboard input not yet working (Datastar keydown binding issue still under investigation).
This commit is contained in:
296
main.go
296
main.go
@@ -1,17 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
g "maragu.dev/gomponents"
|
||||
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
@@ -19,11 +27,43 @@ import (
|
||||
)
|
||||
|
||||
var store = game.NewGameStore()
|
||||
var snakeStore = snake.NewSnakeStore()
|
||||
var queries *gen.Queries
|
||||
|
||||
//go:embed assets/css/output.css
|
||||
var daisyUICSS []byte
|
||||
|
||||
// dataExpr renders an h.H attribute node and extracts the raw Datastar expression.
|
||||
// h.Data/h.Attr HTML-escape values, so we unescape to get the original expression.
|
||||
func dataExpr(node h.H) string {
|
||||
var buf bytes.Buffer
|
||||
node.Render(&buf)
|
||||
s := buf.String()
|
||||
start := strings.Index(s, `="`) + 2
|
||||
end := strings.LastIndex(s, `"`)
|
||||
if start < 2 || end <= start {
|
||||
return ""
|
||||
}
|
||||
return html.UnescapeString(s[start:end])
|
||||
}
|
||||
|
||||
// rawDataAttr outputs an unescaped data attribute. Needed because gomponents
|
||||
// HTML-escapes attribute values, which double-escapes expressions extracted
|
||||
// from rendered nodes. Implements gomponents' attribute interface so it
|
||||
// renders in the element's opening tag rather than as a child.
|
||||
type rawDataAttr struct {
|
||||
name, value string
|
||||
}
|
||||
|
||||
func (a rawDataAttr) Render(w io.Writer) error {
|
||||
_, err := fmt.Fprintf(w, ` %s="%s"`, a.name, a.value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a rawDataAttr) Type() g.NodeType {
|
||||
return g.AttributeType
|
||||
}
|
||||
|
||||
func DaisyUIPlugin(v *via.V) {
|
||||
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
@@ -38,6 +78,7 @@ func main() {
|
||||
}
|
||||
queries = gen.New(db.DB)
|
||||
store.SetPersister(db.NewGamePersister(queries))
|
||||
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||
|
||||
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
||||
if err != nil {
|
||||
@@ -50,18 +91,19 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
store.SetPubSub(ns)
|
||||
snakeStore.SetPubSub(ns)
|
||||
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
LogLevel: via.LogLevelDebug,
|
||||
DocumentTitle: "Connect 4",
|
||||
DocumentTitle: "Game Lobby",
|
||||
ServerAddress: ":7331",
|
||||
SessionManager: sessionManager,
|
||||
PubSub: ns,
|
||||
Plugins: []via.Plugin{DaisyUIPlugin},
|
||||
})
|
||||
|
||||
// Home page - enter nickname and create game
|
||||
// Home page - tabbed lobby
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := c.Session().GetString("user_id")
|
||||
username := c.Session().GetString("username")
|
||||
@@ -89,6 +131,7 @@ func main() {
|
||||
if isLoggedIn {
|
||||
nickname = c.Signal(username)
|
||||
}
|
||||
activeTab := c.Signal("connect4")
|
||||
|
||||
logout := c.Action(func() {
|
||||
c.Session().Clear()
|
||||
@@ -118,17 +161,53 @@ func main() {
|
||||
}).OnClick()
|
||||
}
|
||||
|
||||
tabClickConnect4 := c.Action(func() {
|
||||
activeTab.SetValue("connect4")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
tabClickSnake := c.Action(func() {
|
||||
activeTab.SetValue("snake")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
snakeNickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
snakeNickname = c.Signal(username)
|
||||
}
|
||||
|
||||
// Snake create game actions — one per preset
|
||||
var snakePresetClicks []h.H
|
||||
for _, preset := range snake.GridPresets {
|
||||
w, ht := preset.Width, preset.Height
|
||||
snakePresetClicks = append(snakePresetClicks, c.Action(func() {
|
||||
name := snakeNickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
si := snakeStore.Create(w, ht)
|
||||
c.Redirectf("/snake/%s", si.ID())
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LobbyView(
|
||||
nickname.Bind(),
|
||||
createGame.OnKeyDown("Enter"),
|
||||
createGame.OnClick(),
|
||||
isLoggedIn,
|
||||
username,
|
||||
logout.OnClick(),
|
||||
userGames,
|
||||
deleteGame,
|
||||
)
|
||||
return ui.LobbyView(ui.LobbyProps{
|
||||
NicknameBind: nickname.Bind(),
|
||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||
CreateGameClick: createGame.OnClick(),
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
LogoutClick: logout.OnClick(),
|
||||
UserGames: userGames,
|
||||
DeleteGameClick: deleteGame,
|
||||
ActiveTab: activeTab.String(),
|
||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||
TabClickSnake: tabClickSnake.OnClick(),
|
||||
SnakeNicknameBind: snakeNickname.Bind(),
|
||||
SnakePresetClicks: snakePresetClicks,
|
||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +329,7 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Game page
|
||||
// Connect 4 game page
|
||||
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
@@ -278,19 +357,16 @@ func main() {
|
||||
var gi *game.GameInstance
|
||||
var gameExists bool
|
||||
|
||||
// Look up game (may not exist during warmup or invalid ID)
|
||||
if gameID != "" {
|
||||
gi, gameExists = store.Get(gameID)
|
||||
}
|
||||
|
||||
// Generate a stable player ID for this session
|
||||
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
playerID = game.PlayerID(game.GenerateID(8))
|
||||
c.Session().Set("player_id", string(playerID))
|
||||
}
|
||||
|
||||
// Use user_id as player_id if logged in
|
||||
if sessionUserID != "" {
|
||||
playerID = game.PlayerID(sessionUserID)
|
||||
}
|
||||
@@ -305,7 +381,6 @@ func main() {
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
// Try to join if not already in game
|
||||
if gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
@@ -344,12 +419,10 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to game updates so the opponent's moves trigger a re-render
|
||||
if gameExists {
|
||||
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
||||
}
|
||||
|
||||
// If nickname exists in session and game exists, join immediately
|
||||
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
@@ -364,7 +437,6 @@ func main() {
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
// Game not found - redirect to home
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
@@ -372,9 +444,7 @@ func main() {
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
// Need nickname first / not joined yet
|
||||
if myColor == 0 {
|
||||
// Unauthenticated user who hasn't chosen to continue as guest
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
@@ -391,7 +461,6 @@ func main() {
|
||||
|
||||
g := gi.GetGame()
|
||||
|
||||
// Create column click function
|
||||
columnClick := func(col int) h.H {
|
||||
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
||||
}
|
||||
@@ -404,7 +473,6 @@ func main() {
|
||||
ui.BoardComponent(g, columnClick, myColor),
|
||||
)
|
||||
|
||||
// Show invite link when waiting for opponent
|
||||
if g.Status == game.StatusWaitingForPlayer {
|
||||
content = append(content, ui.InviteLink(g.ID))
|
||||
}
|
||||
@@ -415,5 +483,185 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Snake game page
|
||||
v.Page("/snake/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
goToRegister := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/register")
|
||||
})
|
||||
|
||||
continueAsGuest := c.Action(func() {
|
||||
showGuestPrompt.SetValue(true)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
var si *snake.SnakeGameInstance
|
||||
var gameExists bool
|
||||
|
||||
if gameID != "" {
|
||||
si, gameExists = snakeStore.Get(gameID)
|
||||
}
|
||||
|
||||
playerID := snake.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
pid := game.GenerateID(8)
|
||||
playerID = snake.PlayerID(pid)
|
||||
c.Session().Set("player_id", pid)
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
playerID = snake.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
if si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
// Direction input: single action with a direction signal
|
||||
dirSignal := c.Signal(-1)
|
||||
handleDir := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
return
|
||||
}
|
||||
dir := snake.Direction(dirSignal.Int())
|
||||
si.SetDirection(slot, dir)
|
||||
})
|
||||
|
||||
createRematch := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
newSI := si.CreateRematch()
|
||||
if newSI != nil {
|
||||
c.Redirectf("/snake/%s", newSI.ID())
|
||||
}
|
||||
})
|
||||
|
||||
if gameExists {
|
||||
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
|
||||
}
|
||||
|
||||
// Auto-join if nickname exists
|
||||
if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
if mySlot < 0 {
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
continueAsGuest.OnClick(),
|
||||
goToRegister.OnClick(),
|
||||
)
|
||||
}
|
||||
return ui.NicknamePrompt(
|
||||
nickname.Bind(),
|
||||
setNickname.OnKeyDown("Enter"),
|
||||
setNickname.OnClick(),
|
||||
)
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")),
|
||||
ui.SnakePlayerList(sg, mySlot),
|
||||
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
||||
)
|
||||
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
content = append(content, ui.SnakeBoard(sg))
|
||||
}
|
||||
|
||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
||||
}
|
||||
|
||||
// Build keydown attributes with unique __suffix names so the
|
||||
// browser doesn't deduplicate them (all share data-on:keydown).
|
||||
type keyBinding struct {
|
||||
suffix string
|
||||
key string
|
||||
dir snake.Direction
|
||||
}
|
||||
bindings := []keyBinding{
|
||||
{"arrowup", "ArrowUp", snake.DirUp},
|
||||
{"arrowdown", "ArrowDown", snake.DirDown},
|
||||
{"arrowleft", "ArrowLeft", snake.DirLeft},
|
||||
{"arrowright", "ArrowRight", snake.DirRight},
|
||||
{"w", "w", snake.DirUp},
|
||||
{"s", "s", snake.DirDown},
|
||||
{"a", "a", snake.DirLeft},
|
||||
{"d", "d", snake.DirRight},
|
||||
}
|
||||
|
||||
wrapperAttrs := []h.H{
|
||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||
h.Attr("tabindex", "0"),
|
||||
h.Data("on:load", "this.focus()"),
|
||||
}
|
||||
for _, kb := range bindings {
|
||||
expr := dataExpr(handleDir.OnKeyDown(kb.key, via.WithSignalInt(dirSignal, int(kb.dir))))
|
||||
wrapperAttrs = append(wrapperAttrs, h.H(rawDataAttr{
|
||||
name: "data-on:keydown__" + kb.suffix,
|
||||
value: expr,
|
||||
}))
|
||||
}
|
||||
|
||||
wrapperAttrs = append(wrapperAttrs, content...)
|
||||
return h.Main(wrapperAttrs...)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user