From f454e0d2201fff3e0ef44ae35d8370c3249e8ec4 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:33:02 -1000 Subject: [PATCH] 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 --- db/gen/games.sql.go | 16 +++++-- db/gen/models.go | 2 + db/gen/snake_games.sql.go | 22 +++++++--- db/migrations/004_add_snake_mode.sql | 7 +++ db/persister.go | 4 ++ db/queries/snake_games.sql | 8 ++-- main.go | 24 +++++++--- snake/logic.go | 6 ++- snake/loop.go | 53 +++++++++++++++------- snake/store.go | 17 ++++--- snake/types.go | 10 +++++ ui/lobby.go | 31 ++++++------- ui/snakelobby.go | 66 +++++++++++++++++++--------- ui/snakestatus.go | 17 ++++++- 14 files changed, 205 insertions(+), 78 deletions(-) create mode 100644 db/migrations/004_add_snake_mode.sql diff --git a/db/gen/games.sql.go b/db/gen/games.sql.go index c4cbd16..535c921 100644 --- a/db/gen/games.sql.go +++ b/db/gen/games.sql.go @@ -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, 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 { @@ -45,6 +45,8 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ) return i, err } @@ -85,7 +87,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, 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) { @@ -111,6 +113,8 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ); err != nil { return nil, err } @@ -126,7 +130,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, 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) { @@ -146,6 +150,8 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) { &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ) return i, err } @@ -186,7 +192,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, 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 WHERE gp.user_id = ? ORDER BY g.updated_at DESC @@ -215,6 +221,8 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ( &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ); err != nil { return nil, err } diff --git a/db/gen/models.go b/db/gen/models.go index 862a802..343e284 100644 --- a/db/gen/models.go +++ b/db/gen/models.go @@ -22,6 +22,8 @@ type Game struct { GridWidth sql.NullInt64 GridHeight sql.NullInt64 MaxPlayers int64 + GameMode int64 + Score int64 } type GamePlayer struct { diff --git a/db/gen/snake_games.sql.go b/db/gen/snake_games.sql.go index accc7ea..2314308 100644 --- a/db/gen/snake_games.sql.go +++ b/db/gen/snake_games.sql.go @@ -11,9 +11,9 @@ import ( ) const createSnakeGame = `-- name: CreateSnakeGame :one -INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players) -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 +INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode) +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, game_mode, score ` type CreateSnakeGameParams struct { @@ -22,6 +22,7 @@ type CreateSnakeGameParams struct { Status int64 GridWidth sql.NullInt64 GridHeight sql.NullInt64 + GameMode int64 } 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.GridWidth, arg.GridHeight, + arg.GameMode, ) var i Game err := row.Scan( @@ -47,6 +49,8 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ) return i, err } @@ -87,7 +91,7 @@ func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error { } 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) { @@ -113,6 +117,8 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ); err != nil { return nil, err } @@ -128,7 +134,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { } 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) { @@ -148,6 +154,8 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) { &i.GridWidth, &i.GridHeight, &i.MaxPlayers, + &i.GameMode, + &i.Score, ) return i, err } @@ -239,7 +247,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt const updateSnakeGame = `-- name: UpdateSnakeGame :exec 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' ` @@ -248,6 +256,7 @@ type UpdateSnakeGameParams struct { Status int64 WinnerUserID sql.NullString RematchGameID sql.NullString + Score int64 ID string } @@ -257,6 +266,7 @@ func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams arg.Status, arg.WinnerUserID, arg.RematchGameID, + arg.Score, arg.ID, ) return err diff --git a/db/migrations/004_add_snake_mode.sql b/db/migrations/004_add_snake_mode.sql new file mode 100644 index 0000000..a977239 --- /dev/null +++ b/db/migrations/004_add_snake_mode.sql @@ -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; diff --git a/db/persister.go b/db/persister.go index a145f9f..891ba39 100644 --- a/db/persister.go +++ b/db/persister.go @@ -171,6 +171,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error { Status: int64(sg.Status), GridWidth: gridWidth, GridHeight: gridHeight, + GameMode: int64(sg.Mode), }) return err } @@ -193,6 +194,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error { Status: int64(sg.Status), WinnerUserID: winnerUserID, RematchGameID: rematchGameID, + Score: int64(sg.Score), ID: sg.ID, }) } @@ -220,6 +222,8 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) { State: state, Players: make([]*snake.Player, 8), Status: snake.Status(row.Status), + Mode: snake.GameMode(row.GameMode), + Score: int(row.Score), } if row.RematchGameID.Valid { diff --git a/db/queries/snake_games.sql b/db/queries/snake_games.sql index 3d9e09a..3a8d5cc 100644 --- a/db/queries/snake_games.sql +++ b/db/queries/snake_games.sql @@ -1,6 +1,6 @@ -- name: CreateSnakeGame :one -INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players) -VALUES (?, ?, 0, ?, 'snake', ?, ?, 8) +INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode) +VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?) RETURNING *; -- name: GetSnakeGame :one @@ -8,14 +8,14 @@ SELECT * FROM games WHERE id = ? AND game_type = 'snake'; -- name: UpdateSnakeGame :exec 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'; -- name: DeleteSnakeGame :exec DELETE FROM games WHERE id = ? AND game_type = 'snake'; -- 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 INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) diff --git a/main.go b/main.go index 7f6d6aa..1278aa6 100644 --- a/main.go +++ b/main.go @@ -152,17 +152,27 @@ func main() { snakeNickname = c.Signal(username) } - // Snake create game actions — one per preset - var snakePresetClicks []h.H + // Snake create game actions — one per preset for solo and multiplayer + var snakeSoloClicks []h.H + var snakeMultiClicks []h.H for _, preset := range snake.GridPresets { w, ht := preset.Width, preset.Height - snakePresetClicks = append(snakePresetClicks, c.Action(func() { + snakeSoloClicks = append(snakeSoloClicks, c.Action(func() { name := snakeNickname.String() if name == "" { return } 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()) }).OnClick()) } @@ -181,7 +191,8 @@ func main() { TabClickConnect4: tabClickConnect4.OnClick(), TabClickSnake: tabClickSnake.OnClick(), SnakeNicknameBind: snakeNickname.Bind(), - SnakePresetClicks: snakePresetClicks, + SnakeSoloClicks: snakeSoloClicks, + SnakeMultiClicks: snakeMultiClicks, ActiveSnakeGames: snakeStore.ActiveGames(), }) }) @@ -599,7 +610,8 @@ func main() { 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)) } diff --git a/snake/logic.go b/snake/logic.go index fff75ed..c48c6ef 100644 --- a/snake/logic.go +++ b/snake/logic.go @@ -152,8 +152,12 @@ func RemoveFood(state *GameState, indices []int) { } // 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 + if mode == ModeSinglePlayer { + target = 1 + } for len(state.Food) < target { p := randomEmptyCell(state) if p == nil { diff --git a/snake/loop.go b/snake/loop.go index 208fdc8..82fbd03 100644 --- a/snake/loop.go +++ b/snake/loop.go @@ -5,17 +5,23 @@ import ( ) const ( - targetFPS = 60 - tickInterval = time.Second / targetFPS - snakeSpeed = 7 // cells per second - moveInterval = time.Second / snakeSpeed - countdownSeconds = 10 - inactivityLimit = 60 * time.Second + targetFPS = 60 + tickInterval = time.Second / targetFPS + snakeSpeed = 7 // cells per second + moveInterval = time.Second / snakeSpeed + countdownSecondsMultiplayer = 10 + countdownSecondsSinglePlayer = 3 + inactivityLimit = 60 * time.Second ) func (si *SnakeGameInstance) startOrResetCountdownLocked() { 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.stopCh = make(chan struct{}) @@ -86,7 +92,7 @@ func (si *SnakeGameInstance) initGame() { state := si.game.State state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height) - SpawnFood(state, len(activeSlots)) + SpawnFood(state, len(activeSlots), si.game.Mode) } func (si *SnakeGameInstance) gamePhase() { @@ -152,16 +158,33 @@ func (si *SnakeGameInstance) gamePhase() { // Check food eaten (only by surviving snakes) eaten := CheckFood(state) 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 alive := AliveCount(state) - if alive <= 1 { - si.game.Status = StatusFinished - winnerIdx := LastAlive(state) - if winnerIdx >= 0 && winnerIdx < len(si.game.Players) { - si.game.Winner = si.game.Players[winnerIdx] + gameOver := false + if si.game.Mode == ModeSinglePlayer { + // Single player ends when the player dies (alive == 0) + if alive == 0 { + 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 { @@ -171,7 +194,7 @@ func (si *SnakeGameInstance) gamePhase() { si.gameMu.Unlock() si.notify() - if alive <= 1 { + if gameOver { return } } diff --git a/snake/store.go b/snake/store.go index 9438cda..b36c2ca 100644 --- a/snake/store.go +++ b/snake/store.go @@ -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) sg := &SnakeGame{ ID: id, @@ -57,6 +57,7 @@ func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance { }, Players: make([]*Player, 8), Status: StatusWaitingForPlayers, + Mode: mode, } si := &SnakeGameInstance{ game: sg, @@ -134,8 +135,8 @@ func (ss *SnakeStore) Delete(id string) error { return nil } -// ActiveGames returns metadata of games that can be joined. -// Copies game data to avoid holding nested locks. +// ActiveGames returns metadata of multiplayer games that can be joined. +// Single player games are excluded. Copies game data to avoid holding nested locks. func (ss *SnakeStore) ActiveGames() []*SnakeGame { ss.gamesMu.RLock() instances := make([]*SnakeGameInstance, 0, len(ss.games)) @@ -148,7 +149,7 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame { for _, si := range instances { si.gameMu.RLock() g := si.game - if g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown { + if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) { games = append(games, g) } si.gameMu.RUnlock() @@ -220,7 +221,10 @@ func (si *SnakeGameInstance) Join(player *Player) bool { 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() } @@ -267,9 +271,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance { // (which acquires gamesMu) to avoid lock ordering deadlock. width := si.game.State.Width height := si.game.State.Height + mode := si.game.Mode si.gameMu.Unlock() - newSI := si.store.Create(width, height) + newSI := si.store.Create(width, height, mode) newID := newSI.ID() si.gameMu.Lock() diff --git a/snake/types.go b/snake/types.go index 0359398..4e0b9eb 100644 --- a/snake/types.go +++ b/snake/types.go @@ -14,6 +14,13 @@ const ( 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. func (d Direction) Opposite(other Direction) bool { switch d { @@ -88,6 +95,8 @@ type SnakeGame struct { Winner *Player // nil if draw CountdownEnd time.Time // when countdown reaches 0 RematchGameID *string + Mode GameMode // ModeMultiplayer or ModeSinglePlayer + Score int // tracks food eaten in single player } func (sg *SnakeGame) IsFinished() bool { @@ -132,6 +141,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame { } cp.Players = make([]*Player, len(sg.Players)) copy(cp.Players, sg.Players) + // Mode and Score are value types, already copied by *sg return &cp } diff --git a/ui/lobby.go b/ui/lobby.go index 94d8bbb..1f0e8a5 100644 --- a/ui/lobby.go +++ b/ui/lobby.go @@ -6,20 +6,21 @@ import ( ) type LobbyProps struct { - NicknameBind h.H - CreateGameKeyDown h.H - CreateGameClick h.H - IsLoggedIn bool - Username string - LogoutClick h.H - UserGames []GameListItem - DeleteGameClick func(id string) h.H - ActiveTab string - TabClickConnect4 h.H - TabClickSnake h.H - SnakeNicknameBind h.H - SnakePresetClicks []h.H - ActiveSnakeGames []*snake.SnakeGame + NicknameBind h.H + CreateGameKeyDown h.H + CreateGameClick h.H + IsLoggedIn bool + Username string + LogoutClick h.H + UserGames []GameListItem + DeleteGameClick func(id string) h.H + ActiveTab string + TabClickConnect4 h.H + TabClickSnake h.H + SnakeNicknameBind h.H + SnakeSoloClicks []h.H + SnakeMultiClicks []h.H + ActiveSnakeGames []*snake.SnakeGame } func LobbyView(p LobbyProps) h.H { @@ -40,7 +41,7 @@ func LobbyView(p LobbyProps) h.H { var tabContent h.H if p.ActiveTab == "snake" { - tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames) + tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames) } else { tabContent = connect4LobbyContent(p) } diff --git a/ui/snakelobby.go b/ui/snakelobby.go index 24fd505..d35e59d 100644 --- a/ui/snakelobby.go +++ b/ui/snakelobby.go @@ -7,14 +7,32 @@ import ( "github.com/ryanhamamura/via/h" ) -func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H { - var presetButtons []h.H +func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame) h.H { + // Solo play buttons + var soloButtons []h.H for i, preset := range snake.GridPresets { var click h.H - if i < len(presetClicks) { - click = presetClicks[i] + if i < len(soloClicks) { + 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.Class("btn btn-primary"), 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"), - h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Game")), - h.Div(h.Class("mb-4"), - h.FieldSet(h.Class("fieldset"), - h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")), - h.Input( - h.Class("input input-bordered w-full"), - h.ID("snake-nickname"), - h.Type("text"), - h.Placeholder("Enter your nickname"), - nicknameBind, - h.Attr("required"), - ), + nicknameField := h.Div(h.Class("mb-4"), + h.FieldSet(h.Class("fieldset"), + h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")), + h.Input( + h.Class("input input-bordered w-full"), + h.ID("snake-nickname"), + h.Type("text"), + h.Placeholder("Enter your nickname"), + 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 @@ -68,7 +92,9 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn } return h.Div( - createSection, + nicknameField, + soloSection, + multiSection, gameListEl, ) } diff --git a/ui/snakestatus.go b/ui/snakestatus.go index 7998aa7..e50b989 100644 --- a/ui/snakestatus.go +++ b/ui/snakestatus.go @@ -12,6 +12,11 @@ import ( func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H { switch sg.Status { 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"), 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"), h.Text("Go!"), ) @@ -42,7 +53,11 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H { case snake.StatusFinished: var msg 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 { msg = "You win!" class = "alert alert-success text-xl font-bold"