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:
@@ -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, 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 {
|
||||
@@ -47,6 +47,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -87,7 +88,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, 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) {
|
||||
@@ -115,6 +116,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,7 +132,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, 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) {
|
||||
@@ -152,6 +154,7 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -192,7 +195,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, 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
|
||||
WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC
|
||||
@@ -223,6 +226,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Game struct {
|
||||
MaxPlayers int64
|
||||
GameMode int64
|
||||
Score int64
|
||||
SnakeSpeed int64
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
|
||||
@@ -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, 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
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||
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, snake_speed
|
||||
`
|
||||
|
||||
type CreateSnakeGameParams struct {
|
||||
@@ -23,6 +23,7 @@ type CreateSnakeGameParams struct {
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
GameMode int64
|
||||
SnakeSpeed int64
|
||||
}
|
||||
|
||||
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.GridHeight,
|
||||
arg.GameMode,
|
||||
arg.SnakeSpeed,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
@@ -51,6 +53,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -91,7 +94,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, 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) {
|
||||
@@ -119,6 +122,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,7 +138,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, 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) {
|
||||
@@ -156,6 +160,7 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
5
db/migrations/005_add_snake_speed.sql
Normal file
5
db/migrations/005_add_snake_speed.sql
Normal 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;
|
||||
@@ -172,6 +172,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
||||
GridWidth: gridWidth,
|
||||
GridHeight: gridHeight,
|
||||
GameMode: int64(sg.Mode),
|
||||
SnakeSpeed: int64(sg.Speed),
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -224,6 +225,7 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
|
||||
Status: snake.Status(row.Status),
|
||||
Mode: snake.GameMode(row.GameMode),
|
||||
Score: int(row.Score),
|
||||
Speed: int(row.SnakeSpeed),
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?)
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSnakeGame :one
|
||||
|
||||
59
main.go
59
main.go
@@ -152,6 +152,19 @@ func main() {
|
||||
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
|
||||
var snakeSoloClicks []h.H
|
||||
var snakeMultiClicks []h.H
|
||||
@@ -163,7 +176,12 @@ func main() {
|
||||
return
|
||||
}
|
||||
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())
|
||||
}).OnClick())
|
||||
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
|
||||
@@ -172,28 +190,35 @@ func main() {
|
||||
return
|
||||
}
|
||||
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())
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LobbyView(ui.LobbyProps{
|
||||
NicknameBind: nickname.Bind(),
|
||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||
CreateGameClick: createGame.OnClick(),
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
LogoutClick: logout.OnClick(),
|
||||
UserGames: userGames,
|
||||
DeleteGameClick: deleteGame,
|
||||
ActiveTab: activeTab.String(),
|
||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||
TabClickSnake: tabClickSnake.OnClick(),
|
||||
SnakeNicknameBind: snakeNickname.Bind(),
|
||||
SnakeSoloClicks: snakeSoloClicks,
|
||||
SnakeMultiClicks: snakeMultiClicks,
|
||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||
NicknameBind: nickname.Bind(),
|
||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||
CreateGameClick: createGame.OnClick(),
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
LogoutClick: logout.OnClick(),
|
||||
UserGames: userGames,
|
||||
DeleteGameClick: deleteGame,
|
||||
ActiveTab: activeTab.String(),
|
||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||
TabClickSnake: tabClickSnake.OnClick(),
|
||||
SnakeNicknameBind: snakeNickname.Bind(),
|
||||
SnakeSoloClicks: snakeSoloClicks,
|
||||
SnakeMultiClicks: snakeMultiClicks,
|
||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||
SelectedSpeedIndex: selectedSpeedIndex.Int(),
|
||||
SpeedSelectClicks: speedSelectClicks,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
targetFPS = 60
|
||||
tickInterval = time.Second / targetFPS
|
||||
snakeSpeed = 7 // cells per second
|
||||
moveInterval = time.Second / snakeSpeed
|
||||
countdownSecondsMultiplayer = 10
|
||||
countdownSecondsSinglePlayer = 3
|
||||
inactivityLimit = 60 * time.Second
|
||||
targetFPS = 60
|
||||
tickInterval = time.Second / targetFPS
|
||||
countdownSecondsMultiplayer = 10
|
||||
countdownSecondsSinglePlayer = 3
|
||||
inactivityLimit = 60 * time.Second
|
||||
)
|
||||
|
||||
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
||||
@@ -114,18 +112,13 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply pending directions every tick for responsive input
|
||||
inputReceived := false
|
||||
// Track input activity for inactivity timeout
|
||||
for i := 0; i < 8; i++ {
|
||||
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
|
||||
si.game.State.Snakes[i].Dir = *si.pendingDir[i]
|
||||
si.pendingDir[i] = nil
|
||||
inputReceived = true
|
||||
if len(si.pendingDirQueue[i]) > 0 {
|
||||
lastInput = time.Now()
|
||||
break
|
||||
}
|
||||
}
|
||||
if inputReceived {
|
||||
lastInput = time.Now()
|
||||
}
|
||||
|
||||
// Inactivity timeout
|
||||
if time.Since(lastInput) > inactivityLimit {
|
||||
@@ -138,7 +131,14 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
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
|
||||
if moveAccum < moveInterval {
|
||||
si.gameMu.Unlock()
|
||||
@@ -146,6 +146,14 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
}
|
||||
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
|
||||
|
||||
// Advance snakes
|
||||
|
||||
@@ -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)
|
||||
sg := &SnakeGame{
|
||||
ID: id,
|
||||
@@ -58,6 +61,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstanc
|
||||
Players: make([]*Player, 8),
|
||||
Status: StatusWaitingForPlayers,
|
||||
Mode: mode,
|
||||
Speed: speed,
|
||||
}
|
||||
si := &SnakeGameInstance{
|
||||
game: sg,
|
||||
@@ -158,14 +162,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
||||
}
|
||||
|
||||
type SnakeGameInstance struct {
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
pendingDir [8]*Direction
|
||||
notify func()
|
||||
persister Persister
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
|
||||
notify func()
|
||||
persister Persister
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) ID() string {
|
||||
@@ -231,8 +235,8 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// SetDirection buffers a direction change for the given slot.
|
||||
// The write happens under the game lock to avoid a data race with the game loop.
|
||||
// SetDirection queues a direction change for the given slot.
|
||||
// Validates against the last queued direction (or current snake dir) to prevent 180° turns.
|
||||
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
||||
if slot < 0 || slot >= 8 {
|
||||
return
|
||||
@@ -240,13 +244,28 @@ func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
||||
si.gameMu.Lock()
|
||||
defer si.gameMu.Unlock()
|
||||
|
||||
if si.game.State != nil && slot < len(si.game.State.Snakes) {
|
||||
s := si.game.State.Snakes[slot]
|
||||
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
|
||||
return
|
||||
}
|
||||
if si.game.State == nil || slot >= len(si.game.State.Snakes) {
|
||||
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() {
|
||||
@@ -272,9 +291,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
||||
width := si.game.State.Width
|
||||
height := si.game.State.Height
|
||||
mode := si.game.Mode
|
||||
speed := si.game.Speed
|
||||
si.gameMu.Unlock()
|
||||
|
||||
newSI := si.store.Create(width, height, mode)
|
||||
newSI := si.store.Create(width, height, mode, speed)
|
||||
newID := newSI.ID()
|
||||
|
||||
si.gameMu.Lock()
|
||||
|
||||
@@ -97,8 +97,24 @@ type SnakeGame struct {
|
||||
RematchGameID *string
|
||||
Mode GameMode // ModeMultiplayer or ModeSinglePlayer
|
||||
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 {
|
||||
return sg.Status == StatusFinished
|
||||
}
|
||||
@@ -121,9 +137,11 @@ type GridPreset struct {
|
||||
}
|
||||
|
||||
var GridPresets = []GridPreset{
|
||||
{Name: "Tiny", Width: 15, Height: 15},
|
||||
{Name: "Small", Width: 20, Height: 20},
|
||||
{Name: "Medium", Width: 30, 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.
|
||||
|
||||
@@ -21,6 +21,8 @@ type LobbyProps struct {
|
||||
SnakeSoloClicks []h.H
|
||||
SnakeMultiClicks []h.H
|
||||
ActiveSnakeGames []*snake.SnakeGame
|
||||
SelectedSpeedIndex int
|
||||
SpeedSelectClicks []h.H
|
||||
}
|
||||
|
||||
func LobbyView(p LobbyProps) h.H {
|
||||
@@ -41,7 +43,7 @@ func LobbyView(p LobbyProps) h.H {
|
||||
|
||||
var tabContent h.H
|
||||
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 {
|
||||
tabContent = connect4LobbyContent(p)
|
||||
}
|
||||
|
||||
@@ -45,10 +45,7 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
|
||||
}
|
||||
|
||||
// Cell size scales with grid dimensions
|
||||
cellSize := 20
|
||||
if state.Width <= 20 {
|
||||
cellSize = 24
|
||||
}
|
||||
cellSize := cellSizeForGrid(state.Width, state.Height)
|
||||
|
||||
var rows []h.H
|
||||
for y := 0; y < state.Height; y++ {
|
||||
@@ -94,3 +91,22 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
|
||||
attrs = append(attrs, rows...)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"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
|
||||
var soloButtons []h.H
|
||||
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"),
|
||||
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...)...),
|
||||
@@ -93,6 +116,7 @@ func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames
|
||||
|
||||
return h.Div(
|
||||
nicknameField,
|
||||
speedSelector,
|
||||
soloSection,
|
||||
multiSection,
|
||||
gameListEl,
|
||||
|
||||
Reference in New Issue
Block a user