feat: add single player snake mode
Add solo mode where players survive as long as possible while tracking score (food eaten). Single player games start with a shorter 3-second countdown vs 10 seconds for multiplayer, maintain exactly 1 food item for classic snake feel, and end when the player dies rather than when one player remains. - Add GameMode type (ModeMultiplayer/ModeSinglePlayer) and Score field - Filter single player games from "Join a Game" lobby list - Show "Ready?" and "Score: X" UI for single player mode - Hide invite link for single player games - Preserve game mode on rematch
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user