Files
games/snake/loop.go
Ryan Hamamura e239e948ae 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
2026-02-04 10:02:40 -10:00

211 lines
4.4 KiB
Go

package snake
import (
"time"
)
const (
targetFPS = 60
tickInterval = time.Second / targetFPS
countdownSecondsMultiplayer = 10
countdownSecondsSinglePlayer = 3
inactivityLimit = 60 * time.Second
)
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
si.game.Status = StatusCountdown
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{})
go si.runLoop()
})
}
func (si *SnakeGameInstance) runLoop() {
si.countdownPhase()
si.gameMu.RLock()
stopped := si.game.Status != StatusInProgress
si.gameMu.RUnlock()
if stopped {
return
}
si.gamePhase()
}
func (si *SnakeGameInstance) countdownPhase() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-si.stopCh:
return
case <-ticker.C:
si.gameMu.Lock()
if si.game.Status != StatusCountdown {
si.gameMu.Unlock()
return
}
remaining := time.Until(si.game.CountdownEnd)
if remaining <= 0 {
si.initGame()
si.game.Status = StatusInProgress
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return
}
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
}
}
}
// initGame sets up snakes and food for the start of a game.
func (si *SnakeGameInstance) initGame() {
// Collect active player slots
var activeSlots []int
for i, p := range si.game.Players {
if p != nil {
activeSlots = append(activeSlots, i)
}
}
state := si.game.State
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
SpawnFood(state, len(activeSlots), si.game.Mode)
}
func (si *SnakeGameInstance) gamePhase() {
ticker := time.NewTicker(tickInterval)
defer ticker.Stop()
lastInput := time.Now()
var moveAccum time.Duration
for {
select {
case <-si.stopCh:
return
case <-ticker.C:
si.gameMu.Lock()
if si.game.Status != StatusInProgress {
si.gameMu.Unlock()
return
}
// Track input activity for inactivity timeout
for i := 0; i < 8; i++ {
if len(si.pendingDirQueue[i]) > 0 {
lastInput = time.Now()
break
}
}
// Inactivity timeout
if time.Since(lastInput) > inactivityLimit {
si.game.Status = StatusFinished
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return
}
// 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()
continue
}
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
Tick(state)
// Check collisions first (before food, so dead snakes don't eat)
dead := CheckCollisions(state)
MarkDead(state, dead)
// Check food eaten (only by surviving snakes)
eaten := CheckFood(state)
RemoveFood(state, eaten)
SpawnFood(state, si.game.PlayerCount(), si.game.Mode)
// Track score for single player
si.game.Score += len(eaten)
// Check game over
alive := AliveCount(state)
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 {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
if gameOver {
return
}
}
}
}