feat: add configurable speed and expanded grid presets for snake

- Add per-game speed setting with presets (Slow/Normal/Fast/Insane)
- Add speed selector UI in snake lobby
- Expand grid presets with Tiny (15x15) and XL (50x30)
- Auto-calculate cell size based on grid dimensions
- Preserve speed setting in rematch games
This commit is contained in:
Ryan Hamamura
2026-02-04 10:02:40 -10:00
parent f454e0d220
commit e239e948ae
13 changed files with 199 additions and 69 deletions

View File

@@ -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, game_mode, score 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, snake_speed
` `
type CreateGameParams struct { type CreateGameParams struct {
@@ -47,6 +47,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -87,7 +88,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, game_mode, score 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, snake_speed 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) {
@@ -115,6 +116,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -130,7 +132,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, game_mode, score 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, snake_speed 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) {
@@ -152,6 +154,7 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -192,7 +195,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, g.game_mode, g.score 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, g.snake_speed 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
@@ -223,6 +226,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -24,6 +24,7 @@ type Game struct {
MaxPlayers int64 MaxPlayers int64
GameMode int64 GameMode int64
Score int64 Score int64
SnakeSpeed int64
} }
type GamePlayer struct { type GamePlayer struct {

View File

@@ -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, game_mode) INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
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, game_mode, score 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, snake_speed
` `
type CreateSnakeGameParams struct { type CreateSnakeGameParams struct {
@@ -23,6 +23,7 @@ type CreateSnakeGameParams struct {
GridWidth sql.NullInt64 GridWidth sql.NullInt64
GridHeight sql.NullInt64 GridHeight sql.NullInt64
GameMode int64 GameMode int64
SnakeSpeed int64
} }
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) { func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
@@ -33,6 +34,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
arg.GridWidth, arg.GridWidth,
arg.GridHeight, arg.GridHeight,
arg.GameMode, arg.GameMode,
arg.SnakeSpeed,
) )
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -51,6 +53,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -91,7 +94,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, game_mode, score FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0 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, snake_speed 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) {
@@ -119,6 +122,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -134,7 +138,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, game_mode, score 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, snake_speed 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) {
@@ -156,6 +160,7 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode, &i.GameMode,
&i.Score, &i.Score,
&i.SnakeSpeed,
) )
return i, err return i, err
} }

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE games ADD COLUMN snake_speed INTEGER NOT NULL DEFAULT 7;
-- +goose Down
ALTER TABLE games DROP COLUMN snake_speed;

View File

@@ -172,6 +172,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
GridWidth: gridWidth, GridWidth: gridWidth,
GridHeight: gridHeight, GridHeight: gridHeight,
GameMode: int64(sg.Mode), GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
}) })
return err return err
} }
@@ -224,6 +225,7 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
Status: snake.Status(row.Status), Status: snake.Status(row.Status),
Mode: snake.GameMode(row.GameMode), Mode: snake.GameMode(row.GameMode),
Score: int(row.Score), Score: int(row.Score),
Speed: int(row.SnakeSpeed),
} }
if row.RematchGameID.Valid { if row.RematchGameID.Valid {

View File

@@ -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, game_mode) INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?) VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
RETURNING *; RETURNING *;
-- name: GetSnakeGame :one -- name: GetSnakeGame :one

59
main.go
View File

@@ -152,6 +152,19 @@ func main() {
snakeNickname = c.Signal(username) snakeNickname = c.Signal(username)
} }
// Speed selection signal (index into SpeedPresets, default to Normal which is index 1)
selectedSpeedIndex := c.Signal(1)
// Speed selector actions
var speedSelectClicks []h.H
for i := range snake.SpeedPresets {
idx := i
speedSelectClicks = append(speedSelectClicks, c.Action(func() {
selectedSpeedIndex.SetValue(idx)
c.Sync()
}).OnClick())
}
// Snake create game actions — one per preset for solo and multiplayer // Snake create game actions — one per preset for solo and multiplayer
var snakeSoloClicks []h.H var snakeSoloClicks []h.H
var snakeMultiClicks []h.H var snakeMultiClicks []h.H
@@ -163,7 +176,12 @@ func main() {
return return
} }
c.Session().Set("nickname", name) c.Session().Set("nickname", name)
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer) speedIdx := selectedSpeedIndex.Int()
speed := snake.DefaultSpeed
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
speed = snake.SpeedPresets[speedIdx].Speed
}
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed)
c.Redirectf("/snake/%s", si.ID()) c.Redirectf("/snake/%s", si.ID())
}).OnClick()) }).OnClick())
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() { snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
@@ -172,28 +190,35 @@ func main() {
return return
} }
c.Session().Set("nickname", name) c.Session().Set("nickname", name)
si := snakeStore.Create(w, ht, snake.ModeMultiplayer) speedIdx := selectedSpeedIndex.Int()
speed := snake.DefaultSpeed
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
speed = snake.SpeedPresets[speedIdx].Speed
}
si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed)
c.Redirectf("/snake/%s", si.ID()) c.Redirectf("/snake/%s", si.ID())
}).OnClick()) }).OnClick())
} }
c.View(func() h.H { c.View(func() h.H {
return ui.LobbyView(ui.LobbyProps{ return ui.LobbyView(ui.LobbyProps{
NicknameBind: nickname.Bind(), NicknameBind: nickname.Bind(),
CreateGameKeyDown: createGame.OnKeyDown("Enter"), CreateGameKeyDown: createGame.OnKeyDown("Enter"),
CreateGameClick: createGame.OnClick(), CreateGameClick: createGame.OnClick(),
IsLoggedIn: isLoggedIn, IsLoggedIn: isLoggedIn,
Username: username, Username: username,
LogoutClick: logout.OnClick(), LogoutClick: logout.OnClick(),
UserGames: userGames, UserGames: userGames,
DeleteGameClick: deleteGame, DeleteGameClick: deleteGame,
ActiveTab: activeTab.String(), ActiveTab: activeTab.String(),
TabClickConnect4: tabClickConnect4.OnClick(), TabClickConnect4: tabClickConnect4.OnClick(),
TabClickSnake: tabClickSnake.OnClick(), TabClickSnake: tabClickSnake.OnClick(),
SnakeNicknameBind: snakeNickname.Bind(), SnakeNicknameBind: snakeNickname.Bind(),
SnakeSoloClicks: snakeSoloClicks, SnakeSoloClicks: snakeSoloClicks,
SnakeMultiClicks: snakeMultiClicks, SnakeMultiClicks: snakeMultiClicks,
ActiveSnakeGames: snakeStore.ActiveGames(), ActiveSnakeGames: snakeStore.ActiveGames(),
SelectedSpeedIndex: selectedSpeedIndex.Int(),
SpeedSelectClicks: speedSelectClicks,
}) })
}) })
}) })

View File

@@ -5,13 +5,11 @@ import (
) )
const ( const (
targetFPS = 60 targetFPS = 60
tickInterval = time.Second / targetFPS tickInterval = time.Second / targetFPS
snakeSpeed = 7 // cells per second countdownSecondsMultiplayer = 10
moveInterval = time.Second / snakeSpeed countdownSecondsSinglePlayer = 3
countdownSecondsMultiplayer = 10 inactivityLimit = 60 * time.Second
countdownSecondsSinglePlayer = 3
inactivityLimit = 60 * time.Second
) )
func (si *SnakeGameInstance) startOrResetCountdownLocked() { func (si *SnakeGameInstance) startOrResetCountdownLocked() {
@@ -114,18 +112,13 @@ func (si *SnakeGameInstance) gamePhase() {
return return
} }
// Apply pending directions every tick for responsive input // Track input activity for inactivity timeout
inputReceived := false
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil { if len(si.pendingDirQueue[i]) > 0 {
si.game.State.Snakes[i].Dir = *si.pendingDir[i] lastInput = time.Now()
si.pendingDir[i] = nil break
inputReceived = true
} }
} }
if inputReceived {
lastInput = time.Now()
}
// Inactivity timeout // Inactivity timeout
if time.Since(lastInput) > inactivityLimit { if time.Since(lastInput) > inactivityLimit {
@@ -138,7 +131,14 @@ func (si *SnakeGameInstance) gamePhase() {
return return
} }
// Only advance game state at snakeSpeed // Compute move interval from per-game speed
speed := si.game.Speed
if speed <= 0 {
speed = DefaultSpeed
}
moveInterval := time.Second / time.Duration(speed)
// Only advance game state at game speed
moveAccum += tickInterval moveAccum += tickInterval
if moveAccum < moveInterval { if moveAccum < moveInterval {
si.gameMu.Unlock() si.gameMu.Unlock()
@@ -146,6 +146,14 @@ func (si *SnakeGameInstance) gamePhase() {
} }
moveAccum -= moveInterval moveAccum -= moveInterval
// Pop one direction from queue per movement frame
for i := 0; i < 8; i++ {
if len(si.pendingDirQueue[i]) > 0 && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
si.game.State.Snakes[i].Dir = si.pendingDirQueue[i][0]
si.pendingDirQueue[i] = si.pendingDirQueue[i][1:]
}
}
state := si.game.State state := si.game.State
// Advance snakes // Advance snakes

View File

@@ -47,7 +47,10 @@ func (ss *SnakeStore) makeNotify(gameID string) func() {
} }
} }
func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstance { func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *SnakeGameInstance {
if speed <= 0 {
speed = DefaultSpeed
}
id := generateID(4) id := generateID(4)
sg := &SnakeGame{ sg := &SnakeGame{
ID: id, ID: id,
@@ -58,6 +61,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstanc
Players: make([]*Player, 8), Players: make([]*Player, 8),
Status: StatusWaitingForPlayers, Status: StatusWaitingForPlayers,
Mode: mode, Mode: mode,
Speed: speed,
} }
si := &SnakeGameInstance{ si := &SnakeGameInstance{
game: sg, game: sg,
@@ -158,14 +162,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
} }
type SnakeGameInstance struct { type SnakeGameInstance struct {
game *SnakeGame game *SnakeGame
gameMu sync.RWMutex gameMu sync.RWMutex
pendingDir [8]*Direction pendingDirQueue [8][]Direction // queued directions per slot (max 3)
notify func() notify func()
persister Persister persister Persister
store *SnakeStore store *SnakeStore
stopCh chan struct{} stopCh chan struct{}
loopOnce sync.Once loopOnce sync.Once
} }
func (si *SnakeGameInstance) ID() string { func (si *SnakeGameInstance) ID() string {
@@ -231,8 +235,8 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
return true return true
} }
// SetDirection buffers a direction change for the given slot. // SetDirection queues a direction change for the given slot.
// The write happens under the game lock to avoid a data race with the game loop. // Validates against the last queued direction (or current snake dir) to prevent 180° turns.
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) { func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
if slot < 0 || slot >= 8 { if slot < 0 || slot >= 8 {
return return
@@ -240,13 +244,28 @@ func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
si.gameMu.Lock() si.gameMu.Lock()
defer si.gameMu.Unlock() defer si.gameMu.Unlock()
if si.game.State != nil && slot < len(si.game.State.Snakes) { if si.game.State == nil || slot >= len(si.game.State.Snakes) {
s := si.game.State.Snakes[slot] return
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
return
}
} }
si.pendingDir[slot] = &dir s := si.game.State.Snakes[slot]
if s == nil || !s.Alive {
return
}
// Validate against last queued direction, or current snake direction if queue empty
refDir := s.Dir
if len(si.pendingDirQueue[slot]) > 0 {
refDir = si.pendingDirQueue[slot][len(si.pendingDirQueue[slot])-1]
}
if !ValidateDirection(refDir, dir) {
return
}
// Cap queue at 3 to prevent unbounded growth
if len(si.pendingDirQueue[slot]) >= 3 {
return
}
si.pendingDirQueue[slot] = append(si.pendingDirQueue[slot], dir)
} }
func (si *SnakeGameInstance) Stop() { func (si *SnakeGameInstance) Stop() {
@@ -272,9 +291,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
width := si.game.State.Width width := si.game.State.Width
height := si.game.State.Height height := si.game.State.Height
mode := si.game.Mode mode := si.game.Mode
speed := si.game.Speed
si.gameMu.Unlock() si.gameMu.Unlock()
newSI := si.store.Create(width, height, mode) newSI := si.store.Create(width, height, mode, speed)
newID := newSI.ID() newID := newSI.ID()
si.gameMu.Lock() si.gameMu.Lock()

View File

@@ -97,8 +97,24 @@ type SnakeGame struct {
RematchGameID *string RematchGameID *string
Mode GameMode // ModeMultiplayer or ModeSinglePlayer Mode GameMode // ModeMultiplayer or ModeSinglePlayer
Score int // tracks food eaten in single player Score int // tracks food eaten in single player
Speed int // cells per second
} }
// Speed presets
type SpeedPreset struct {
Name string
Speed int
}
var SpeedPresets = []SpeedPreset{
{Name: "Slow", Speed: 5},
{Name: "Normal", Speed: 7},
{Name: "Fast", Speed: 10},
{Name: "Insane", Speed: 15},
}
const DefaultSpeed = 7
func (sg *SnakeGame) IsFinished() bool { func (sg *SnakeGame) IsFinished() bool {
return sg.Status == StatusFinished return sg.Status == StatusFinished
} }
@@ -121,9 +137,11 @@ type GridPreset struct {
} }
var GridPresets = []GridPreset{ var GridPresets = []GridPreset{
{Name: "Tiny", Width: 15, Height: 15},
{Name: "Small", Width: 20, Height: 20}, {Name: "Small", Width: 20, Height: 20},
{Name: "Medium", Width: 30, Height: 20}, {Name: "Medium", Width: 30, Height: 20},
{Name: "Large", Width: 40, Height: 20}, {Name: "Large", Width: 40, Height: 20},
{Name: "XL", Width: 50, Height: 30},
} }
// snapshot returns a shallow copy of the game safe for reading outside the lock. // snapshot returns a shallow copy of the game safe for reading outside the lock.

View File

@@ -21,6 +21,8 @@ type LobbyProps struct {
SnakeSoloClicks []h.H SnakeSoloClicks []h.H
SnakeMultiClicks []h.H SnakeMultiClicks []h.H
ActiveSnakeGames []*snake.SnakeGame ActiveSnakeGames []*snake.SnakeGame
SelectedSpeedIndex int
SpeedSelectClicks []h.H
} }
func LobbyView(p LobbyProps) h.H { func LobbyView(p LobbyProps) h.H {
@@ -41,7 +43,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.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames) tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames, p.SelectedSpeedIndex, p.SpeedSelectClicks)
} else { } else {
tabContent = connect4LobbyContent(p) tabContent = connect4LobbyContent(p)
} }

View File

@@ -45,10 +45,7 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
} }
// Cell size scales with grid dimensions // Cell size scales with grid dimensions
cellSize := 20 cellSize := cellSizeForGrid(state.Width, state.Height)
if state.Width <= 20 {
cellSize = 24
}
var rows []h.H var rows []h.H
for y := 0; y < state.Height; y++ { for y := 0; y < state.Height; y++ {
@@ -94,3 +91,22 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
attrs = append(attrs, rows...) attrs = append(attrs, rows...)
return h.Div(attrs...) return h.Div(attrs...)
} }
func cellSizeForGrid(width, height int) int {
maxDim := width
if height > maxDim {
maxDim = height
}
switch {
case maxDim <= 15:
return 28
case maxDim <= 20:
return 24
case maxDim <= 30:
return 20
case maxDim <= 40:
return 16
default:
return 14
}
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame) h.H { func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame, selectedSpeedIndex int, speedSelectClicks []h.H) h.H {
// Solo play buttons // Solo play buttons
var soloButtons []h.H var soloButtons []h.H
for i, preset := range snake.GridPresets { for i, preset := range snake.GridPresets {
@@ -56,6 +56,29 @@ func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames
), ),
) )
// Speed selector
var speedButtons []h.H
for i, preset := range snake.SpeedPresets {
btnClass := "btn btn-sm"
if i == selectedSpeedIndex {
btnClass += " btn-active"
}
var click h.H
if i < len(speedSelectClicks) {
click = speedSelectClicks[i]
}
speedButtons = append(speedButtons, h.Button(
h.Class(btnClass),
h.Type("button"),
h.Text(preset.Name),
click,
))
}
speedSelector := h.Div(h.Class("mb-4"),
h.Label(h.Class("label"), h.Text("Speed")),
h.Div(append([]h.H{h.Class("btn-group")}, speedButtons...)...),
)
soloSection := h.Div(h.Class("mb-6"), soloSection := h.Div(h.Class("mb-6"),
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Play Solo")), 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...)...), h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, soloButtons...)...),
@@ -93,6 +116,7 @@ func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames
return h.Div( return h.Div(
nicknameField, nicknameField,
speedSelector,
soloSection, soloSection,
multiSection, multiSection,
gameListEl, gameListEl,