CSS animations for smoother 60fps feel despite 7Hz game ticks: - 130ms transitions on cell background/box-shadow - Head pop-in animation on direction changes - Food pulse animation - Smooth death state fade with grayscale Per-snake colored glow on head cells. Make server port configurable via PORT env var (default 8080). Add deploy/ with systemd service and scripts: - setup.sh: create games user, /opt/c4, install unit - deploy.sh: build and install binary, restart service - package.sh: cross-compile, tarball, base64 split for transfer - reassemble.sh: decode and extract on target server
624 lines
14 KiB
Go
624 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
_ "embed"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"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"
|
|
"github.com/ryanhamamura/via/vianats"
|
|
)
|
|
|
|
var (
|
|
store = game.NewGameStore()
|
|
snakeStore = snake.NewSnakeStore()
|
|
queries *gen.Queries
|
|
)
|
|
|
|
//go:embed assets/css/output.css
|
|
var daisyUICSS []byte
|
|
|
|
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")
|
|
_, _ = w.Write(daisyUICSS)
|
|
})
|
|
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/_plugins/daisyui/style.css")))
|
|
}
|
|
|
|
func port() string {
|
|
if p := os.Getenv("PORT"); p != "" {
|
|
return p
|
|
}
|
|
return "7331"
|
|
}
|
|
|
|
func main() {
|
|
if err := db.Init("c4.db"); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
queries = gen.New(db.DB)
|
|
store.SetPersister(db.NewGamePersister(queries))
|
|
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
|
|
|
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ns, err := vianats.New(ctx, "./data/nats")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
store.SetPubSub(ns)
|
|
snakeStore.SetPubSub(ns)
|
|
|
|
v := via.New()
|
|
v.Config(via.Options{
|
|
LogLevel: via.LogLevelDebug,
|
|
DocumentTitle: "Game Lobby",
|
|
ServerAddress: ":" + port(),
|
|
SessionManager: sessionManager,
|
|
PubSub: ns,
|
|
Plugins: []via.Plugin{DaisyUIPlugin},
|
|
})
|
|
|
|
// Home page - tabbed lobby
|
|
v.Page("/", func(c *via.Context) {
|
|
userID := c.Session().GetString("user_id")
|
|
username := c.Session().GetString("username")
|
|
isLoggedIn := userID != ""
|
|
|
|
var userGames []ui.GameListItem
|
|
if isLoggedIn {
|
|
ctx := context.Background()
|
|
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true})
|
|
if err == nil {
|
|
for _, g := range games {
|
|
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
|
|
userGames = append(userGames, ui.GameListItem{
|
|
ID: g.ID,
|
|
Status: int(g.Status),
|
|
OpponentName: g.OpponentNickname.String,
|
|
IsMyTurn: isMyTurn,
|
|
LastPlayed: g.UpdatedAt.Time,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
nickname := c.Signal("")
|
|
if isLoggedIn {
|
|
nickname = c.Signal(username)
|
|
}
|
|
activeTab := c.Signal("connect4")
|
|
|
|
logout := c.Action(func() {
|
|
c.Session().Clear()
|
|
c.Redirect("/")
|
|
})
|
|
|
|
createGame := c.Action(func() {
|
|
name := nickname.String()
|
|
if name == "" {
|
|
return
|
|
}
|
|
c.Session().Set("nickname", name)
|
|
|
|
gi := store.Create()
|
|
c.Redirectf("/game/%s", gi.ID())
|
|
})
|
|
|
|
deleteGame := func(id string) h.H {
|
|
return c.Action(func() {
|
|
for _, g := range userGames {
|
|
if g.ID == id {
|
|
store.Delete(id)
|
|
break
|
|
}
|
|
}
|
|
c.Redirect("/")
|
|
}).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(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(),
|
|
})
|
|
})
|
|
})
|
|
|
|
// Login page
|
|
v.Page("/login", func(c *via.Context) {
|
|
username := c.Signal("")
|
|
password := c.Signal("")
|
|
errorMsg := c.Signal("")
|
|
|
|
login := c.Action(func() {
|
|
ctx := context.Background()
|
|
user, err := queries.GetUserByUsername(ctx, username.String())
|
|
if err == sql.ErrNoRows {
|
|
errorMsg.SetValue("Invalid username or password")
|
|
c.Sync()
|
|
return
|
|
}
|
|
if err != nil {
|
|
errorMsg.SetValue("An error occurred")
|
|
c.Sync()
|
|
return
|
|
}
|
|
if !auth.CheckPassword(password.String(), user.PasswordHash) {
|
|
errorMsg.SetValue("Invalid username or password")
|
|
c.Sync()
|
|
return
|
|
}
|
|
|
|
c.Session().Set("user_id", user.ID)
|
|
c.Session().Set("username", user.Username)
|
|
c.Session().Set("nickname", user.Username)
|
|
|
|
returnURL := c.Session().GetString("return_url")
|
|
if returnURL != "" {
|
|
c.Session().Set("return_url", "")
|
|
c.Redirect(returnURL)
|
|
} else {
|
|
c.Redirect("/")
|
|
}
|
|
})
|
|
|
|
c.View(func() h.H {
|
|
return ui.LoginView(
|
|
username.Bind(),
|
|
password.Bind(),
|
|
login.OnKeyDown("Enter"),
|
|
login.OnClick(),
|
|
errorMsg.String(),
|
|
)
|
|
})
|
|
})
|
|
|
|
// Register page
|
|
v.Page("/register", func(c *via.Context) {
|
|
username := c.Signal("")
|
|
password := c.Signal("")
|
|
confirm := c.Signal("")
|
|
errorMsg := c.Signal("")
|
|
|
|
register := c.Action(func() {
|
|
if err := auth.ValidateUsername(username.String()); err != nil {
|
|
errorMsg.SetValue(err.Error())
|
|
c.Sync()
|
|
return
|
|
}
|
|
if err := auth.ValidatePassword(password.String()); err != nil {
|
|
errorMsg.SetValue(err.Error())
|
|
c.Sync()
|
|
return
|
|
}
|
|
if password.String() != confirm.String() {
|
|
errorMsg.SetValue("Passwords do not match")
|
|
c.Sync()
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(password.String())
|
|
if err != nil {
|
|
errorMsg.SetValue("An error occurred")
|
|
c.Sync()
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
id := uuid.New().String()
|
|
user, err := queries.CreateUser(ctx, gen.CreateUserParams{
|
|
ID: id,
|
|
Username: username.String(),
|
|
PasswordHash: hash,
|
|
})
|
|
if err != nil {
|
|
errorMsg.SetValue("Username already taken")
|
|
c.Sync()
|
|
return
|
|
}
|
|
|
|
c.Session().Set("user_id", user.ID)
|
|
c.Session().Set("username", user.Username)
|
|
c.Session().Set("nickname", user.Username)
|
|
|
|
returnURL := c.Session().GetString("return_url")
|
|
if returnURL != "" {
|
|
c.Session().Set("return_url", "")
|
|
c.Redirect(returnURL)
|
|
} else {
|
|
c.Redirect("/")
|
|
}
|
|
})
|
|
|
|
c.View(func() h.H {
|
|
return ui.RegisterView(
|
|
username.Bind(),
|
|
password.Bind(),
|
|
confirm.Bind(),
|
|
register.OnKeyDown("Enter"),
|
|
register.OnClick(),
|
|
errorMsg.String(),
|
|
)
|
|
})
|
|
})
|
|
|
|
// Connect 4 game page
|
|
v.Page("/game/{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)
|
|
colSignal := c.Signal(0)
|
|
showGuestPrompt := c.Signal(false)
|
|
|
|
goToLogin := c.Action(func() {
|
|
c.Session().Set("return_url", "/game/"+gameID)
|
|
c.Redirect("/login")
|
|
})
|
|
|
|
goToRegister := c.Action(func() {
|
|
c.Session().Set("return_url", "/game/"+gameID)
|
|
c.Redirect("/register")
|
|
})
|
|
|
|
continueAsGuest := c.Action(func() {
|
|
showGuestPrompt.SetValue(true)
|
|
c.Sync()
|
|
})
|
|
|
|
var gi *game.GameInstance
|
|
var gameExists bool
|
|
|
|
if gameID != "" {
|
|
gi, gameExists = store.Get(gameID)
|
|
}
|
|
|
|
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
|
if playerID == "" {
|
|
playerID = game.PlayerID(game.GenerateID(8))
|
|
c.Session().Set("player_id", string(playerID))
|
|
}
|
|
|
|
if sessionUserID != "" {
|
|
playerID = game.PlayerID(sessionUserID)
|
|
}
|
|
|
|
setNickname := c.Action(func() {
|
|
if gi == nil {
|
|
return
|
|
}
|
|
name := nickname.String()
|
|
if name == "" {
|
|
return
|
|
}
|
|
c.Session().Set("nickname", name)
|
|
|
|
if gi.GetPlayerColor(playerID) == 0 {
|
|
player := &game.Player{
|
|
ID: playerID,
|
|
Nickname: name,
|
|
}
|
|
if sessionUserID != "" {
|
|
player.UserID = &sessionUserID
|
|
}
|
|
gi.Join(&game.PlayerSession{
|
|
Player: player,
|
|
})
|
|
}
|
|
c.Sync()
|
|
})
|
|
|
|
dropPiece := c.Action(func() {
|
|
if gi == nil {
|
|
return
|
|
}
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
if myColor == 0 {
|
|
return
|
|
}
|
|
col := colSignal.Int()
|
|
gi.DropPiece(col, myColor)
|
|
c.Sync()
|
|
})
|
|
|
|
createRematch := c.Action(func() {
|
|
if gi == nil {
|
|
return
|
|
}
|
|
newGI := gi.CreateRematch(store)
|
|
if newGI != nil {
|
|
c.Redirectf("/game/%s", newGI.ID())
|
|
}
|
|
})
|
|
|
|
if gameExists {
|
|
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
|
}
|
|
|
|
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
|
player := &game.Player{
|
|
ID: playerID,
|
|
Nickname: sessionNickname,
|
|
}
|
|
if sessionUserID != "" {
|
|
player.UserID = &sessionUserID
|
|
}
|
|
gi.Join(&game.PlayerSession{
|
|
Player: player,
|
|
})
|
|
}
|
|
|
|
c.View(func() h.H {
|
|
if !gameExists {
|
|
c.Redirect("/")
|
|
return h.Div()
|
|
}
|
|
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
|
|
if myColor == 0 {
|
|
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
|
return ui.GameJoinPrompt(
|
|
goToLogin.OnClick(),
|
|
continueAsGuest.OnClick(),
|
|
goToRegister.OnClick(),
|
|
)
|
|
}
|
|
return ui.NicknamePrompt(
|
|
nickname.Bind(),
|
|
setNickname.OnKeyDown("Enter"),
|
|
setNickname.OnClick(),
|
|
)
|
|
}
|
|
|
|
g := gi.GetGame()
|
|
|
|
columnClick := func(col int) h.H {
|
|
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
|
}
|
|
|
|
var content []h.H
|
|
content = append(content,
|
|
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
|
|
ui.PlayerInfo(g, myColor),
|
|
ui.StatusBanner(g, myColor, createRematch.OnClick()),
|
|
ui.BoardComponent(g, columnClick, myColor),
|
|
)
|
|
|
|
if g.Status == game.StatusWaitingForPlayer {
|
|
content = append(content, ui.InviteLink(g.ID))
|
|
}
|
|
|
|
mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")}
|
|
mainAttrs = append(mainAttrs, content...)
|
|
return h.Main(mainAttrs...)
|
|
})
|
|
})
|
|
|
|
// 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))
|
|
}
|
|
|
|
wrapperAttrs := []h.H{
|
|
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
|
via.OnKeyDownMap(
|
|
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp))),
|
|
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft))),
|
|
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown))),
|
|
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight))),
|
|
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault()),
|
|
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault()),
|
|
via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault()),
|
|
via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault()),
|
|
),
|
|
}
|
|
|
|
wrapperAttrs = append(wrapperAttrs, content...)
|
|
return h.Main(wrapperAttrs...)
|
|
})
|
|
})
|
|
|
|
v.Start()
|
|
}
|