Wrap free persistence functions in instance methods for cleaner call sites (gi.save() instead of saveGame(gi.queries, gi.game)). Methods log errors via zerolog before returning them.
209 lines
4.4 KiB
Go
209 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.queries != nil {
|
|
si.save() //nolint:errcheck
|
|
}
|
|
si.gameMu.Unlock()
|
|
si.notify()
|
|
return
|
|
}
|
|
|
|
// No DB save during countdown ticks — state is transient
|
|
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()
|
|
lastSave := 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.queries != nil {
|
|
si.save() //nolint:errcheck
|
|
}
|
|
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 {
|
|
if alive == 0 {
|
|
gameOver = true
|
|
}
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// Throttle DB saves: persist on game over or every 2 seconds
|
|
if si.queries != nil && (gameOver || time.Since(lastSave) >= 2*time.Second) {
|
|
si.save() //nolint:errcheck
|
|
lastSave = time.Now()
|
|
}
|
|
|
|
si.gameMu.Unlock()
|
|
si.notify()
|
|
|
|
if gameOver {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|