Files
games/main.go
Ryan Hamamura 2cd5b1d289 Add play again button for rematch after game ends
When a game finishes (win or draw), players see a "Play again" button.
Clicking it creates a new game and the opponent sees a "Join Rematch"
link to join the same game.
2026-01-14 18:02:26 -10:00

638 lines
12 KiB
Go

package main
import (
"context"
"database/sql"
"log"
"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/ui"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var store = game.NewGameStore()
var queries *gen.Queries
func main() {
if err := db.Init("c4.db"); err != nil {
log.Fatal(err)
}
queries = gen.New(db.DB)
store.SetPersister(db.NewGamePersister(queries))
v := via.New()
v.Config(via.Options{
LogLvl: via.LogLevelDebug,
DocumentTitle: "Connect 4",
ServerAddress: ":7331",
})
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(gameCSS)),
)
// Home page - enter nickname and create game
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)
}
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()
}
c.View(func() h.H {
return ui.LobbyView(
nickname.Bind(),
createGame.OnKeyDown("Enter"),
createGame.OnClick(),
isLoggedIn,
username,
logout.OnClick(),
userGames,
deleteGame,
)
})
})
// 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)
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)
c.Redirect("/")
})
c.View(func() h.H {
return ui.RegisterView(
username.Bind(),
password.Bind(),
confirm.Bind(),
register.OnKeyDown("Enter"),
register.OnClick(),
errorMsg.String(),
)
})
})
// 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)
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)
}
setNickname := c.Action(func() {
if gi == nil {
return
}
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
// Try to join if not already in game
if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
ID: playerID,
Nickname: name,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
gi.Join(&game.PlayerSession{
Player: player,
Sync: c,
})
}
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 nickname exists in session and game exists, join immediately
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,
Sync: c,
})
}
c.View(func() h.H {
// Game not found - redirect to home
if !gameExists {
c.Redirect("/")
return h.Div()
}
myColor := gi.GetPlayerColor(playerID)
// Need nickname first / not joined yet
if myColor == 0 {
return ui.NicknamePrompt(
nickname.Bind(),
setNickname.OnKeyDown("Enter"),
setNickname.OnClick(),
)
}
g := gi.GetGame()
// Create column click function
columnClick := func(col int) h.H {
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
}
var content []h.H
content = append(content,
h.H1(h.Text("Connect 4")),
ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor, createRematch.OnClick()),
ui.BoardComponent(g, columnClick, myColor),
)
// Show invite link when waiting for opponent
if g.Status == game.StatusWaitingForPlayer {
content = append(content, ui.InviteLink(g.ID))
}
mainAttrs := []h.H{h.Class("container game-container")}
mainAttrs = append(mainAttrs, content...)
return h.Main(mainAttrs...)
})
})
v.Start()
}
const gameCSS = `
body { margin: 0; }
.lobby {
max-width: 400px;
margin: 2rem auto;
text-align: center;
}
.game-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.board {
display: flex;
gap: 8px;
background: #2563eb;
padding: 16px;
border-radius: 12px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px;
border-radius: 8px;
}
.column.clickable {
cursor: pointer;
}
.column.clickable:hover {
background: rgba(255,255,255,0.15);
}
.cell {
width: 48px;
height: 48px;
border-radius: 50%;
background: #1e40af;
transition: background 0.2s;
}
.cell.red {
background: #dc2626;
}
.cell.yellow {
background: #facc15;
}
.cell.winning {
animation: pulse 0.5s ease-in-out infinite alternate;
}
@keyframes pulse {
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); }
}
.status {
font-size: 1.25rem;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 8px;
}
.status.waiting {
background: var(--pico-muted-background);
}
.status.your-turn {
background: #22c55e;
color: white;
}
.status.opponent-turn {
background: var(--pico-muted-background);
}
.status.winner {
background: #22c55e;
color: white;
}
.status.loser {
background: #dc2626;
color: white;
}
.status.draw {
background: #f59e0b;
color: white;
}
.play-again-btn, .rematch-link {
margin-left: 1rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
background: white;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
.play-again-btn:hover, .rematch-link:hover {
background: #eee;
}
.player-info {
display: flex;
gap: 2rem;
margin-bottom: 0.5rem;
}
.player {
display: flex;
align-items: center;
gap: 0.5rem;
}
.player-chip {
width: 20px;
height: 20px;
border-radius: 50%;
background: #666;
}
.player-chip.red {
background: #dc2626;
}
.player-chip.yellow {
background: #facc15;
}
.invite-section {
margin-top: 1rem;
text-align: center;
}
.invite-link {
background: var(--pico-muted-background);
padding: 1rem;
border-radius: 8px;
font-family: monospace;
word-break: break-all;
margin: 0.5rem 0;
}
.copy-btn {
margin-top: 0.5rem;
}
.auth-header {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--pico-muted-background);
border-radius: 8px;
}
.auth-header button {
margin: 0;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.guest-banner {
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--pico-muted-background);
border-radius: 8px;
font-size: 0.875rem;
}
.error {
color: #dc2626;
background: #fef2f2;
padding: 0.5rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.game-list {
margin-top: 2rem;
text-align: left;
}
.game-list h3 {
margin-bottom: 1rem;
text-align: center;
}
.game-list-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.game-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--pico-muted-background);
border-radius: 8px;
transition: background 0.2s;
}
.game-entry:hover {
background: var(--pico-secondary-background);
}
.game-entry-link {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
text-decoration: none;
color: inherit;
}
.game-entry-main {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.game-delete-btn {
width: 2rem;
height: 2rem;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: var(--pico-muted-color);
font-size: 1.25rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.game-delete-btn:hover {
background: #dc2626;
color: white;
}
.opponent-name {
font-weight: bold;
}
.game-status {
font-size: 0.875rem;
}
.game-status.your-turn {
color: #22c55e;
font-weight: bold;
}
.game-status.waiting {
color: var(--pico-muted-color);
}
.time-ago {
font-size: 0.75rem;
color: var(--pico-muted-color);
}
`