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
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
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 {
|
||||
@@ -40,6 +40,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -80,7 +81,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -101,6 +102,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,7 +118,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -131,6 +133,7 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -171,7 +174,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
||||
}
|
||||
|
||||
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
|
||||
WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC
|
||||
@@ -195,6 +198,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,17 +269,18 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
|
||||
|
||||
const updateGame = `-- name: UpdateGame :exec
|
||||
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 = ?
|
||||
`
|
||||
|
||||
type UpdateGameParams struct {
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
RematchGameID sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
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.WinnerUserID,
|
||||
arg.WinningCells,
|
||||
arg.RematchGameID,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
RematchGameID sql.NullString
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
rematchGameID := sql.NullString{}
|
||||
if g.RematchGameID != nil {
|
||||
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
ID: g.ID,
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
RematchGameID: rematchGameID,
|
||||
ID: g.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,6 +80,10 @@ func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
|
||||
g.WinningCellsFromJSON(row.WinningCells.String)
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
g.RematchGameID = &row.RematchGameID.String
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ SELECT * FROM games WHERE id = ?;
|
||||
|
||||
-- name: UpdateGame :exec
|
||||
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 = ?;
|
||||
|
||||
-- name: DeleteGame :exec
|
||||
|
||||
@@ -196,6 +196,30 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
||||
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 {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
@@ -21,13 +21,14 @@ const (
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
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)
|
||||
CurrentTurn int // 1 or 2 (matches player color)
|
||||
Status GameStatus
|
||||
Winner *Player
|
||||
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
||||
ID string
|
||||
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)
|
||||
CurrentTurn int // 1 or 2 (matches player color)
|
||||
Status GameStatus
|
||||
Winner *Player
|
||||
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 {
|
||||
@@ -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 {
|
||||
data, _ := json.Marshal(g.Board)
|
||||
return string(data)
|
||||
|
||||
28
main.go
28
main.go
@@ -281,6 +281,16 @@ func main() {
|
||||
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{
|
||||
@@ -325,7 +335,7 @@ func main() {
|
||||
content = append(content,
|
||||
h.H1(h.Text("Connect 4")),
|
||||
ui.PlayerInfo(g, myColor),
|
||||
ui.StatusBanner(g, myColor),
|
||||
ui.StatusBanner(g, myColor, createRematch.OnClick()),
|
||||
ui.BoardComponent(g, columnClick, myColor),
|
||||
)
|
||||
|
||||
@@ -444,6 +454,22 @@ const gameCSS = `
|
||||
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;
|
||||
|
||||
30
ui/status.go
30
ui/status.go
@@ -5,7 +5,7 @@ import (
|
||||
"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 class string
|
||||
|
||||
@@ -35,10 +35,34 @@ func StatusBanner(g *game.Game, myColor int) h.H {
|
||||
class = "status draw"
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
content := []h.H{
|
||||
h.Class(class),
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user