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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
29
main.go
29
main.go
@@ -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,7 +190,12 @@ 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())
|
||||||
}
|
}
|
||||||
@@ -194,6 +217,8 @@ func main() {
|
|||||||
SnakeSoloClicks: snakeSoloClicks,
|
SnakeSoloClicks: snakeSoloClicks,
|
||||||
SnakeMultiClicks: snakeMultiClicks,
|
SnakeMultiClicks: snakeMultiClicks,
|
||||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||||
|
SelectedSpeedIndex: selectedSpeedIndex.Int(),
|
||||||
|
SpeedSelectClicks: speedSelectClicks,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
targetFPS = 60
|
targetFPS = 60
|
||||||
tickInterval = time.Second / targetFPS
|
tickInterval = time.Second / targetFPS
|
||||||
snakeSpeed = 7 // cells per second
|
|
||||||
moveInterval = time.Second / snakeSpeed
|
|
||||||
countdownSecondsMultiplayer = 10
|
countdownSecondsMultiplayer = 10
|
||||||
countdownSecondsSinglePlayer = 3
|
countdownSecondsSinglePlayer = 3
|
||||||
inactivityLimit = 60 * time.Second
|
inactivityLimit = 60 * time.Second
|
||||||
@@ -114,17 +112,12 @@ 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]
|
|
||||||
si.pendingDir[i] = nil
|
|
||||||
inputReceived = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inputReceived {
|
|
||||||
lastInput = time.Now()
|
lastInput = time.Now()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inactivity timeout
|
// Inactivity timeout
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -160,7 +164,7 @@ 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
|
||||||
@@ -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]
|
|
||||||
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s := si.game.State.Snakes[slot]
|
||||||
|
if s == nil || !s.Alive {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
si.pendingDir[slot] = &dir
|
|
||||||
|
// 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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user