feat: add single player snake mode
Add solo mode where players survive as long as possible while tracking score (food eaten). Single player games start with a shorter 3-second countdown vs 10 seconds for multiplayer, maintain exactly 1 food item for classic snake feel, and end when the player dies rather than when one player remains. - Add GameMode type (ModeMultiplayer/ModeSinglePlayer) and Score field - Filter single player games from "Join a Game" lobby list - Show "Ready?" and "Score: X" UI for single player mode - Hide invite link for single player games - Preserve game mode on rematch
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, rematch_game_id, game_type, grid_width, grid_height, max_players
|
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateGameParams struct {
|
type CreateGameParams struct {
|
||||||
@@ -45,6 +45,8 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -85,7 +87,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, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE game_type = 'connect4' AND status < 2
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE game_type = 'connect4' AND status < 2
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||||
@@ -111,6 +113,8 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -126,7 +130,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, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE id = ?
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score 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) {
|
||||||
@@ -146,6 +150,8 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -186,7 +192,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, g.rematch_game_id, g.game_type, g.grid_width, g.grid_height, g.max_players 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, g.game_type, g.grid_width, g.grid_height, g.max_players, g.game_mode, g.score 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
|
||||||
@@ -215,6 +221,8 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type Game struct {
|
|||||||
GridWidth sql.NullInt64
|
GridWidth sql.NullInt64
|
||||||
GridHeight sql.NullInt64
|
GridHeight sql.NullInt64
|
||||||
MaxPlayers int64
|
MaxPlayers int64
|
||||||
|
GameMode int64
|
||||||
|
Score int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePlayer struct {
|
type GamePlayer struct {
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const createSnakeGame = `-- name: CreateSnakeGame :one
|
const createSnakeGame = `-- name: CreateSnakeGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
|
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode)
|
||||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8)
|
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?)
|
||||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players
|
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateSnakeGameParams struct {
|
type CreateSnakeGameParams struct {
|
||||||
@@ -22,6 +22,7 @@ type CreateSnakeGameParams struct {
|
|||||||
Status int64
|
Status int64
|
||||||
GridWidth sql.NullInt64
|
GridWidth sql.NullInt64
|
||||||
GridHeight sql.NullInt64
|
GridHeight sql.NullInt64
|
||||||
|
GameMode int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
||||||
@@ -31,6 +32,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
|
|||||||
arg.Status,
|
arg.Status,
|
||||||
arg.GridWidth,
|
arg.GridWidth,
|
||||||
arg.GridHeight,
|
arg.GridHeight,
|
||||||
|
arg.GameMode,
|
||||||
)
|
)
|
||||||
var i Game
|
var i Game
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -47,6 +49,8 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -87,7 +91,7 @@ func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
|
const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE game_type = 'snake' AND status < 2
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||||
@@ -113,6 +117,8 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -128,7 +134,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSnakeGame = `-- name: GetSnakeGame :one
|
const getSnakeGame = `-- name: GetSnakeGame :one
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE id = ? AND game_type = 'snake'
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE id = ? AND game_type = 'snake'
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||||
@@ -148,6 +154,8 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -239,7 +247,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
|
|||||||
|
|
||||||
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND game_type = 'snake'
|
WHERE id = ? AND game_type = 'snake'
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -248,6 +256,7 @@ type UpdateSnakeGameParams struct {
|
|||||||
Status int64
|
Status int64
|
||||||
WinnerUserID sql.NullString
|
WinnerUserID sql.NullString
|
||||||
RematchGameID sql.NullString
|
RematchGameID sql.NullString
|
||||||
|
Score int64
|
||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +266,7 @@ func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams
|
|||||||
arg.Status,
|
arg.Status,
|
||||||
arg.WinnerUserID,
|
arg.WinnerUserID,
|
||||||
arg.RematchGameID,
|
arg.RematchGameID,
|
||||||
|
arg.Score,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
7
db/migrations/004_add_snake_mode.sql
Normal file
7
db/migrations/004_add_snake_mode.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE games ADD COLUMN game_mode INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE games ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE games DROP COLUMN score;
|
||||||
|
ALTER TABLE games DROP COLUMN game_mode;
|
||||||
@@ -171,6 +171,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
|||||||
Status: int64(sg.Status),
|
Status: int64(sg.Status),
|
||||||
GridWidth: gridWidth,
|
GridWidth: gridWidth,
|
||||||
GridHeight: gridHeight,
|
GridHeight: gridHeight,
|
||||||
|
GameMode: int64(sg.Mode),
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -193,6 +194,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
|||||||
Status: int64(sg.Status),
|
Status: int64(sg.Status),
|
||||||
WinnerUserID: winnerUserID,
|
WinnerUserID: winnerUserID,
|
||||||
RematchGameID: rematchGameID,
|
RematchGameID: rematchGameID,
|
||||||
|
Score: int64(sg.Score),
|
||||||
ID: sg.ID,
|
ID: sg.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -220,6 +222,8 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
|
|||||||
State: state,
|
State: state,
|
||||||
Players: make([]*snake.Player, 8),
|
Players: make([]*snake.Player, 8),
|
||||||
Status: snake.Status(row.Status),
|
Status: snake.Status(row.Status),
|
||||||
|
Mode: snake.GameMode(row.GameMode),
|
||||||
|
Score: int(row.Score),
|
||||||
}
|
}
|
||||||
|
|
||||||
if row.RematchGameID.Valid {
|
if row.RematchGameID.Valid {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- name: CreateSnakeGame :one
|
-- name: CreateSnakeGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
|
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode)
|
||||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8)
|
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSnakeGame :one
|
-- name: GetSnakeGame :one
|
||||||
@@ -8,14 +8,14 @@ SELECT * FROM games WHERE id = ? AND game_type = 'snake';
|
|||||||
|
|
||||||
-- name: UpdateSnakeGame :exec
|
-- name: UpdateSnakeGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND game_type = 'snake';
|
WHERE id = ? AND game_type = 'snake';
|
||||||
|
|
||||||
-- name: DeleteSnakeGame :exec
|
-- name: DeleteSnakeGame :exec
|
||||||
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
||||||
|
|
||||||
-- name: GetActiveSnakeGames :many
|
-- name: GetActiveSnakeGames :many
|
||||||
SELECT * FROM games WHERE game_type = 'snake' AND status < 2;
|
SELECT * FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0;
|
||||||
|
|
||||||
-- name: CreateSnakePlayer :exec
|
-- name: CreateSnakePlayer :exec
|
||||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||||
|
|||||||
24
main.go
24
main.go
@@ -152,17 +152,27 @@ func main() {
|
|||||||
snakeNickname = c.Signal(username)
|
snakeNickname = c.Signal(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snake create game actions — one per preset
|
// Snake create game actions — one per preset for solo and multiplayer
|
||||||
var snakePresetClicks []h.H
|
var snakeSoloClicks []h.H
|
||||||
|
var snakeMultiClicks []h.H
|
||||||
for _, preset := range snake.GridPresets {
|
for _, preset := range snake.GridPresets {
|
||||||
w, ht := preset.Width, preset.Height
|
w, ht := preset.Width, preset.Height
|
||||||
snakePresetClicks = append(snakePresetClicks, c.Action(func() {
|
snakeSoloClicks = append(snakeSoloClicks, c.Action(func() {
|
||||||
name := snakeNickname.String()
|
name := snakeNickname.String()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Session().Set("nickname", name)
|
c.Session().Set("nickname", name)
|
||||||
si := snakeStore.Create(w, ht)
|
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer)
|
||||||
|
c.Redirectf("/snake/%s", si.ID())
|
||||||
|
}).OnClick())
|
||||||
|
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
|
||||||
|
name := snakeNickname.String()
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Session().Set("nickname", name)
|
||||||
|
si := snakeStore.Create(w, ht, snake.ModeMultiplayer)
|
||||||
c.Redirectf("/snake/%s", si.ID())
|
c.Redirectf("/snake/%s", si.ID())
|
||||||
}).OnClick())
|
}).OnClick())
|
||||||
}
|
}
|
||||||
@@ -181,7 +191,8 @@ func main() {
|
|||||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||||
TabClickSnake: tabClickSnake.OnClick(),
|
TabClickSnake: tabClickSnake.OnClick(),
|
||||||
SnakeNicknameBind: snakeNickname.Bind(),
|
SnakeNicknameBind: snakeNickname.Bind(),
|
||||||
SnakePresetClicks: snakePresetClicks,
|
SnakeSoloClicks: snakeSoloClicks,
|
||||||
|
SnakeMultiClicks: snakeMultiClicks,
|
||||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -599,7 +610,8 @@ func main() {
|
|||||||
content = append(content, ui.SnakeBoard(sg))
|
content = append(content, ui.SnakeBoard(sg))
|
||||||
}
|
}
|
||||||
|
|
||||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
// Only show invite link for multiplayer games
|
||||||
|
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
content = append(content, ui.SnakeInviteLink(sg.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,12 @@ func RemoveFood(state *GameState, indices []int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SpawnFood adds food items to maintain the target count.
|
// SpawnFood adds food items to maintain the target count.
|
||||||
func SpawnFood(state *GameState, playerCount int) {
|
// Single player always maintains exactly 1 food for classic snake feel.
|
||||||
|
func SpawnFood(state *GameState, playerCount int, mode GameMode) {
|
||||||
target := playerCount/2 + 1
|
target := playerCount/2 + 1
|
||||||
|
if mode == ModeSinglePlayer {
|
||||||
|
target = 1
|
||||||
|
}
|
||||||
for len(state.Food) < target {
|
for len(state.Food) < target {
|
||||||
p := randomEmptyCell(state)
|
p := randomEmptyCell(state)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|||||||
@@ -5,17 +5,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
targetFPS = 60
|
targetFPS = 60
|
||||||
tickInterval = time.Second / targetFPS
|
tickInterval = time.Second / targetFPS
|
||||||
snakeSpeed = 7 // cells per second
|
snakeSpeed = 7 // cells per second
|
||||||
moveInterval = time.Second / snakeSpeed
|
moveInterval = time.Second / snakeSpeed
|
||||||
countdownSeconds = 10
|
countdownSecondsMultiplayer = 10
|
||||||
inactivityLimit = 60 * time.Second
|
countdownSecondsSinglePlayer = 3
|
||||||
|
inactivityLimit = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
||||||
si.game.Status = StatusCountdown
|
si.game.Status = StatusCountdown
|
||||||
si.game.CountdownEnd = time.Now().Add(countdownSeconds * time.Second)
|
|
||||||
|
countdown := countdownSecondsMultiplayer
|
||||||
|
if si.game.Mode == ModeSinglePlayer {
|
||||||
|
countdown = countdownSecondsSinglePlayer
|
||||||
|
}
|
||||||
|
si.game.CountdownEnd = time.Now().Add(time.Duration(countdown) * time.Second)
|
||||||
|
|
||||||
si.loopOnce.Do(func() {
|
si.loopOnce.Do(func() {
|
||||||
si.stopCh = make(chan struct{})
|
si.stopCh = make(chan struct{})
|
||||||
@@ -86,7 +92,7 @@ func (si *SnakeGameInstance) initGame() {
|
|||||||
|
|
||||||
state := si.game.State
|
state := si.game.State
|
||||||
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
|
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
|
||||||
SpawnFood(state, len(activeSlots))
|
SpawnFood(state, len(activeSlots), si.game.Mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SnakeGameInstance) gamePhase() {
|
func (si *SnakeGameInstance) gamePhase() {
|
||||||
@@ -152,16 +158,33 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
// Check food eaten (only by surviving snakes)
|
// Check food eaten (only by surviving snakes)
|
||||||
eaten := CheckFood(state)
|
eaten := CheckFood(state)
|
||||||
RemoveFood(state, eaten)
|
RemoveFood(state, eaten)
|
||||||
SpawnFood(state, si.game.PlayerCount())
|
SpawnFood(state, si.game.PlayerCount(), si.game.Mode)
|
||||||
|
|
||||||
|
// Track score for single player
|
||||||
|
si.game.Score += len(eaten)
|
||||||
|
|
||||||
// Check game over
|
// Check game over
|
||||||
alive := AliveCount(state)
|
alive := AliveCount(state)
|
||||||
if alive <= 1 {
|
gameOver := false
|
||||||
si.game.Status = StatusFinished
|
if si.game.Mode == ModeSinglePlayer {
|
||||||
winnerIdx := LastAlive(state)
|
// Single player ends when the player dies (alive == 0)
|
||||||
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
|
if alive == 0 {
|
||||||
si.game.Winner = si.game.Players[winnerIdx]
|
gameOver = true
|
||||||
|
// No winner in single player - just final score
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Multiplayer ends when 1 or fewer alive
|
||||||
|
if alive <= 1 {
|
||||||
|
gameOver = true
|
||||||
|
winnerIdx := LastAlive(state)
|
||||||
|
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
|
||||||
|
si.game.Winner = si.game.Players[winnerIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gameOver {
|
||||||
|
si.game.Status = StatusFinished
|
||||||
}
|
}
|
||||||
|
|
||||||
if si.persister != nil {
|
if si.persister != nil {
|
||||||
@@ -171,7 +194,7 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
si.gameMu.Unlock()
|
si.gameMu.Unlock()
|
||||||
si.notify()
|
si.notify()
|
||||||
|
|
||||||
if alive <= 1 {
|
if gameOver {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (ss *SnakeStore) makeNotify(gameID string) func() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
|
func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstance {
|
||||||
id := generateID(4)
|
id := generateID(4)
|
||||||
sg := &SnakeGame{
|
sg := &SnakeGame{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -57,6 +57,7 @@ func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
|
|||||||
},
|
},
|
||||||
Players: make([]*Player, 8),
|
Players: make([]*Player, 8),
|
||||||
Status: StatusWaitingForPlayers,
|
Status: StatusWaitingForPlayers,
|
||||||
|
Mode: mode,
|
||||||
}
|
}
|
||||||
si := &SnakeGameInstance{
|
si := &SnakeGameInstance{
|
||||||
game: sg,
|
game: sg,
|
||||||
@@ -134,8 +135,8 @@ func (ss *SnakeStore) Delete(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveGames returns metadata of games that can be joined.
|
// ActiveGames returns metadata of multiplayer games that can be joined.
|
||||||
// Copies game data to avoid holding nested locks.
|
// Single player games are excluded. Copies game data to avoid holding nested locks.
|
||||||
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
||||||
ss.gamesMu.RLock()
|
ss.gamesMu.RLock()
|
||||||
instances := make([]*SnakeGameInstance, 0, len(ss.games))
|
instances := make([]*SnakeGameInstance, 0, len(ss.games))
|
||||||
@@ -148,7 +149,7 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
|||||||
for _, si := range instances {
|
for _, si := range instances {
|
||||||
si.gameMu.RLock()
|
si.gameMu.RLock()
|
||||||
g := si.game
|
g := si.game
|
||||||
if g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown {
|
if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) {
|
||||||
games = append(games, g)
|
games = append(games, g)
|
||||||
}
|
}
|
||||||
si.gameMu.RUnlock()
|
si.gameMu.RUnlock()
|
||||||
@@ -220,7 +221,10 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
|
|||||||
|
|
||||||
si.notify()
|
si.notify()
|
||||||
|
|
||||||
if si.game.PlayerCount() >= 2 {
|
// Single player starts countdown immediately when 1 player joins
|
||||||
|
if si.game.Mode == ModeSinglePlayer && si.game.PlayerCount() >= 1 {
|
||||||
|
si.startOrResetCountdownLocked()
|
||||||
|
} else if si.game.Mode == ModeMultiplayer && si.game.PlayerCount() >= 2 {
|
||||||
si.startOrResetCountdownLocked()
|
si.startOrResetCountdownLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +271,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
|||||||
// (which acquires gamesMu) to avoid lock ordering deadlock.
|
// (which acquires gamesMu) to avoid lock ordering deadlock.
|
||||||
width := si.game.State.Width
|
width := si.game.State.Width
|
||||||
height := si.game.State.Height
|
height := si.game.State.Height
|
||||||
|
mode := si.game.Mode
|
||||||
si.gameMu.Unlock()
|
si.gameMu.Unlock()
|
||||||
|
|
||||||
newSI := si.store.Create(width, height)
|
newSI := si.store.Create(width, height, mode)
|
||||||
newID := newSI.ID()
|
newID := newSI.ID()
|
||||||
|
|
||||||
si.gameMu.Lock()
|
si.gameMu.Lock()
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ const (
|
|||||||
DirRight
|
DirRight
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type GameMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeMultiplayer GameMode = iota // Default (0) - backward compatible
|
||||||
|
ModeSinglePlayer // Single player survival mode
|
||||||
|
)
|
||||||
|
|
||||||
// Opposite returns true if a and b are 180-degree reversals.
|
// Opposite returns true if a and b are 180-degree reversals.
|
||||||
func (d Direction) Opposite(other Direction) bool {
|
func (d Direction) Opposite(other Direction) bool {
|
||||||
switch d {
|
switch d {
|
||||||
@@ -88,6 +95,8 @@ type SnakeGame struct {
|
|||||||
Winner *Player // nil if draw
|
Winner *Player // nil if draw
|
||||||
CountdownEnd time.Time // when countdown reaches 0
|
CountdownEnd time.Time // when countdown reaches 0
|
||||||
RematchGameID *string
|
RematchGameID *string
|
||||||
|
Mode GameMode // ModeMultiplayer or ModeSinglePlayer
|
||||||
|
Score int // tracks food eaten in single player
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sg *SnakeGame) IsFinished() bool {
|
func (sg *SnakeGame) IsFinished() bool {
|
||||||
@@ -132,6 +141,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame {
|
|||||||
}
|
}
|
||||||
cp.Players = make([]*Player, len(sg.Players))
|
cp.Players = make([]*Player, len(sg.Players))
|
||||||
copy(cp.Players, sg.Players)
|
copy(cp.Players, sg.Players)
|
||||||
|
// Mode and Score are value types, already copied by *sg
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
ui/lobby.go
31
ui/lobby.go
@@ -6,20 +6,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LobbyProps struct {
|
type LobbyProps struct {
|
||||||
NicknameBind h.H
|
NicknameBind h.H
|
||||||
CreateGameKeyDown h.H
|
CreateGameKeyDown h.H
|
||||||
CreateGameClick h.H
|
CreateGameClick h.H
|
||||||
IsLoggedIn bool
|
IsLoggedIn bool
|
||||||
Username string
|
Username string
|
||||||
LogoutClick h.H
|
LogoutClick h.H
|
||||||
UserGames []GameListItem
|
UserGames []GameListItem
|
||||||
DeleteGameClick func(id string) h.H
|
DeleteGameClick func(id string) h.H
|
||||||
ActiveTab string
|
ActiveTab string
|
||||||
TabClickConnect4 h.H
|
TabClickConnect4 h.H
|
||||||
TabClickSnake h.H
|
TabClickSnake h.H
|
||||||
SnakeNicknameBind h.H
|
SnakeNicknameBind h.H
|
||||||
SnakePresetClicks []h.H
|
SnakeSoloClicks []h.H
|
||||||
ActiveSnakeGames []*snake.SnakeGame
|
SnakeMultiClicks []h.H
|
||||||
|
ActiveSnakeGames []*snake.SnakeGame
|
||||||
}
|
}
|
||||||
|
|
||||||
func LobbyView(p LobbyProps) h.H {
|
func LobbyView(p LobbyProps) h.H {
|
||||||
@@ -40,7 +41,7 @@ func LobbyView(p LobbyProps) h.H {
|
|||||||
|
|
||||||
var tabContent h.H
|
var tabContent h.H
|
||||||
if p.ActiveTab == "snake" {
|
if p.ActiveTab == "snake" {
|
||||||
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames)
|
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames)
|
||||||
} else {
|
} else {
|
||||||
tabContent = connect4LobbyContent(p)
|
tabContent = connect4LobbyContent(p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,32 @@ import (
|
|||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H {
|
func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame) h.H {
|
||||||
var presetButtons []h.H
|
// Solo play buttons
|
||||||
|
var soloButtons []h.H
|
||||||
for i, preset := range snake.GridPresets {
|
for i, preset := range snake.GridPresets {
|
||||||
var click h.H
|
var click h.H
|
||||||
if i < len(presetClicks) {
|
if i < len(soloClicks) {
|
||||||
click = presetClicks[i]
|
click = soloClicks[i]
|
||||||
}
|
}
|
||||||
presetButtons = append(presetButtons,
|
soloButtons = append(soloButtons,
|
||||||
|
h.Button(
|
||||||
|
h.Class("btn btn-secondary"),
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||||
|
click,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiplayer buttons
|
||||||
|
var multiButtons []h.H
|
||||||
|
for i, preset := range snake.GridPresets {
|
||||||
|
var click h.H
|
||||||
|
if i < len(multiClicks) {
|
||||||
|
click = multiClicks[i]
|
||||||
|
}
|
||||||
|
multiButtons = append(multiButtons,
|
||||||
h.Button(
|
h.Button(
|
||||||
h.Class("btn btn-primary"),
|
h.Class("btn btn-primary"),
|
||||||
h.Type("button"),
|
h.Type("button"),
|
||||||
@@ -24,22 +42,28 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
createSection := h.Div(h.Class("mb-6"),
|
nicknameField := h.Div(h.Class("mb-4"),
|
||||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Game")),
|
h.FieldSet(h.Class("fieldset"),
|
||||||
h.Div(h.Class("mb-4"),
|
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
||||||
h.FieldSet(h.Class("fieldset"),
|
h.Input(
|
||||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
h.Class("input input-bordered w-full"),
|
||||||
h.Input(
|
h.ID("snake-nickname"),
|
||||||
h.Class("input input-bordered w-full"),
|
h.Type("text"),
|
||||||
h.ID("snake-nickname"),
|
h.Placeholder("Enter your nickname"),
|
||||||
h.Type("text"),
|
nicknameBind,
|
||||||
h.Placeholder("Enter your nickname"),
|
h.Attr("required"),
|
||||||
nicknameBind,
|
|
||||||
h.Attr("required"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...),
|
)
|
||||||
|
|
||||||
|
soloSection := h.Div(h.Class("mb-6"),
|
||||||
|
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Play Solo")),
|
||||||
|
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, soloButtons...)...),
|
||||||
|
)
|
||||||
|
|
||||||
|
multiSection := h.Div(h.Class("mb-6"),
|
||||||
|
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Multiplayer Game")),
|
||||||
|
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, multiButtons...)...),
|
||||||
)
|
)
|
||||||
|
|
||||||
var gameListEl h.H
|
var gameListEl h.H
|
||||||
@@ -68,7 +92,9 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return h.Div(
|
return h.Div(
|
||||||
createSection,
|
nicknameField,
|
||||||
|
soloSection,
|
||||||
|
multiSection,
|
||||||
gameListEl,
|
gameListEl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import (
|
|||||||
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
||||||
switch sg.Status {
|
switch sg.Status {
|
||||||
case snake.StatusWaitingForPlayers:
|
case snake.StatusWaitingForPlayers:
|
||||||
|
if sg.Mode == snake.ModeSinglePlayer {
|
||||||
|
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||||
|
h.Text("Ready?"),
|
||||||
|
)
|
||||||
|
}
|
||||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||||
h.Text("Waiting for players..."),
|
h.Text("Waiting for players..."),
|
||||||
)
|
)
|
||||||
@@ -35,6 +40,12 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Show score during single player gameplay
|
||||||
|
if sg.Mode == snake.ModeSinglePlayer {
|
||||||
|
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||||
|
h.Text(fmt.Sprintf("Score: %d", sg.Score)),
|
||||||
|
)
|
||||||
|
}
|
||||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||||
h.Text("Go!"),
|
h.Text("Go!"),
|
||||||
)
|
)
|
||||||
@@ -42,7 +53,11 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
|||||||
case snake.StatusFinished:
|
case snake.StatusFinished:
|
||||||
var msg string
|
var msg string
|
||||||
var class string
|
var class string
|
||||||
if sg.Winner != nil {
|
|
||||||
|
if sg.Mode == snake.ModeSinglePlayer {
|
||||||
|
msg = fmt.Sprintf("Game Over! Score: %d", sg.Score)
|
||||||
|
class = "alert alert-info text-xl font-bold"
|
||||||
|
} else if sg.Winner != nil {
|
||||||
if sg.Winner.Slot == mySlot {
|
if sg.Winner.Slot == mySlot {
|
||||||
msg = "You win!"
|
msg = "You win!"
|
||||||
class = "alert alert-success text-xl font-bold"
|
class = "alert alert-success text-xl font-bold"
|
||||||
|
|||||||
Reference in New Issue
Block a user