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.
This commit is contained in:
@@ -13,7 +13,7 @@ import (
|
|||||||
const createGame = `-- name: CreateGame :one
|
const createGame = `-- name: CreateGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status)
|
INSERT INTO games (id, board, current_turn, status)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at
|
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateGameParams struct {
|
type CreateGameParams struct {
|
||||||
@@ -40,6 +40,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
|||||||
&i.WinningCells,
|
&i.WinningCells,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RematchGameID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +81,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActiveGames = `-- name: GetActiveGames :many
|
const getActiveGames = `-- name: GetActiveGames :many
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE status < 2
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id FROM games WHERE status < 2
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||||
@@ -101,6 +102,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
|||||||
&i.WinningCells,
|
&i.WinningCells,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RematchGameID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -116,7 +118,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getGame = `-- name: GetGame :one
|
const getGame = `-- name: GetGame :one
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE id = ?
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id FROM games WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||||
@@ -131,6 +133,7 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
|||||||
&i.WinningCells,
|
&i.WinningCells,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RematchGameID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -171,7 +174,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getGamesByUserID = `-- name: GetGamesByUserID :many
|
const getGamesByUserID = `-- name: GetGamesByUserID :many
|
||||||
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at FROM games g
|
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id FROM games g
|
||||||
JOIN game_players gp ON g.id = gp.game_id
|
JOIN game_players gp ON g.id = gp.game_id
|
||||||
WHERE gp.user_id = ?
|
WHERE gp.user_id = ?
|
||||||
ORDER BY g.updated_at DESC
|
ORDER BY g.updated_at DESC
|
||||||
@@ -195,6 +198,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
|||||||
&i.WinningCells,
|
&i.WinningCells,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RematchGameID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -265,17 +269,18 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
|
|||||||
|
|
||||||
const updateGame = `-- name: UpdateGame :exec
|
const updateGame = `-- name: UpdateGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateGameParams struct {
|
type UpdateGameParams struct {
|
||||||
Board string
|
Board string
|
||||||
CurrentTurn int64
|
CurrentTurn int64
|
||||||
Status int64
|
Status int64
|
||||||
WinnerUserID sql.NullString
|
WinnerUserID sql.NullString
|
||||||
WinningCells sql.NullString
|
WinningCells sql.NullString
|
||||||
ID string
|
RematchGameID sql.NullString
|
||||||
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
|
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
|
||||||
@@ -285,6 +290,7 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
|
|||||||
arg.Status,
|
arg.Status,
|
||||||
arg.WinnerUserID,
|
arg.WinnerUserID,
|
||||||
arg.WinningCells,
|
arg.WinningCells,
|
||||||
|
arg.RematchGameID,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
ID string
|
ID string
|
||||||
Board string
|
Board string
|
||||||
CurrentTurn int64
|
CurrentTurn int64
|
||||||
Status int64
|
Status int64
|
||||||
WinnerUserID sql.NullString
|
WinnerUserID sql.NullString
|
||||||
WinningCells sql.NullString
|
WinningCells sql.NullString
|
||||||
CreatedAt sql.NullTime
|
CreatedAt sql.NullTime
|
||||||
UpdatedAt sql.NullTime
|
UpdatedAt sql.NullTime
|
||||||
|
RematchGameID sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePlayer struct {
|
type GamePlayer struct {
|
||||||
|
|||||||
5
db/migrations/002_add_rematch.sql
Normal file
5
db/migrations/002_add_rematch.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE games ADD COLUMN rematch_game_id TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE games DROP COLUMN rematch_game_id;
|
||||||
@@ -43,13 +43,19 @@ func (p *GamePersister) SaveGame(g *game.Game) error {
|
|||||||
winningCells = sql.NullString{String: wc, Valid: true}
|
winningCells = sql.NullString{String: wc, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rematchGameID := sql.NullString{}
|
||||||
|
if g.RematchGameID != nil {
|
||||||
|
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
|
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
|
||||||
Board: g.BoardToJSON(),
|
Board: g.BoardToJSON(),
|
||||||
CurrentTurn: int64(g.CurrentTurn),
|
CurrentTurn: int64(g.CurrentTurn),
|
||||||
Status: int64(g.Status),
|
Status: int64(g.Status),
|
||||||
WinnerUserID: winnerUserID,
|
WinnerUserID: winnerUserID,
|
||||||
WinningCells: winningCells,
|
WinningCells: winningCells,
|
||||||
ID: g.ID,
|
RematchGameID: rematchGameID,
|
||||||
|
ID: g.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,10 @@ func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
|
|||||||
g.WinningCellsFromJSON(row.WinningCells.String)
|
g.WinningCellsFromJSON(row.WinningCells.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if row.RematchGameID.Valid {
|
||||||
|
g.RematchGameID = &row.RematchGameID.String
|
||||||
|
}
|
||||||
|
|
||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ SELECT * FROM games WHERE id = ?;
|
|||||||
|
|
||||||
-- name: UpdateGame :exec
|
-- name: UpdateGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteGame :exec
|
-- name: DeleteGame :exec
|
||||||
|
|||||||
@@ -196,6 +196,30 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
|
||||||
|
gi.gameMu.Lock()
|
||||||
|
defer gi.gameMu.Unlock()
|
||||||
|
|
||||||
|
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newGI := gs.Create()
|
||||||
|
newID := newGI.ID()
|
||||||
|
gi.game.RematchGameID = &newID
|
||||||
|
|
||||||
|
if gi.persister != nil {
|
||||||
|
if err := gi.persister.SaveGame(gi.game); err != nil {
|
||||||
|
gs.Delete(newID)
|
||||||
|
gi.game.RematchGameID = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gi.dirty = true
|
||||||
|
return newGI
|
||||||
|
}
|
||||||
|
|
||||||
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||||
gi.gameMu.Lock()
|
gi.gameMu.Lock()
|
||||||
defer gi.gameMu.Unlock()
|
defer gi.gameMu.Unlock()
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
ID string
|
ID string
|
||||||
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
||||||
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
||||||
CurrentTurn int // 1 or 2 (matches player color)
|
CurrentTurn int // 1 or 2 (matches player color)
|
||||||
Status GameStatus
|
Status GameStatus
|
||||||
Winner *Player
|
Winner *Player
|
||||||
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
||||||
|
RematchGameID *string // ID of the rematch game, if one was created
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGame(id string) *Game {
|
func NewGame(id string) *Game {
|
||||||
@@ -39,6 +40,10 @@ func NewGame(id string) *Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *Game) IsFinished() bool {
|
||||||
|
return g.Status == StatusWon || g.Status == StatusDraw
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Game) BoardToJSON() string {
|
func (g *Game) BoardToJSON() string {
|
||||||
data, _ := json.Marshal(g.Board)
|
data, _ := json.Marshal(g.Board)
|
||||||
return string(data)
|
return string(data)
|
||||||
|
|||||||
28
main.go
28
main.go
@@ -281,6 +281,16 @@ func main() {
|
|||||||
c.Sync()
|
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 nickname exists in session and game exists, join immediately
|
||||||
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||||
player := &game.Player{
|
player := &game.Player{
|
||||||
@@ -325,7 +335,7 @@ func main() {
|
|||||||
content = append(content,
|
content = append(content,
|
||||||
h.H1(h.Text("Connect 4")),
|
h.H1(h.Text("Connect 4")),
|
||||||
ui.PlayerInfo(g, myColor),
|
ui.PlayerInfo(g, myColor),
|
||||||
ui.StatusBanner(g, myColor),
|
ui.StatusBanner(g, myColor, createRematch.OnClick()),
|
||||||
ui.BoardComponent(g, columnClick, myColor),
|
ui.BoardComponent(g, columnClick, myColor),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -444,6 +454,22 @@ const gameCSS = `
|
|||||||
color: white;
|
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 {
|
.player-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
|||||||
30
ui/status.go
30
ui/status.go
@@ -5,7 +5,7 @@ import (
|
|||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StatusBanner(g *game.Game, myColor int) h.H {
|
func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
|
||||||
var message string
|
var message string
|
||||||
var class string
|
var class string
|
||||||
|
|
||||||
@@ -35,10 +35,34 @@ func StatusBanner(g *game.Game, myColor int) h.H {
|
|||||||
class = "status draw"
|
class = "status draw"
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.Div(
|
content := []h.H{
|
||||||
h.Class(class),
|
h.Class(class),
|
||||||
h.Text(message),
|
h.Text(message),
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// Show rematch options for finished games
|
||||||
|
if g.IsFinished() {
|
||||||
|
if g.RematchGameID != nil {
|
||||||
|
content = append(content,
|
||||||
|
h.A(
|
||||||
|
h.Class("rematch-link"),
|
||||||
|
h.Href("/game/"+*g.RematchGameID),
|
||||||
|
h.Text("Join Rematch"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if playAgainClick != nil {
|
||||||
|
content = append(content,
|
||||||
|
h.Button(
|
||||||
|
h.Class("play-again-btn"),
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text("Play again"),
|
||||||
|
playAgainClick,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.Div(content...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOpponentName(g *game.Game, myColor int) string {
|
func getOpponentName(g *game.Game, myColor int) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user