Inline persistence logic directly into game stores and handlers: - game/persist.go: DB mapping methods on GameStore and GameInstance - snake/persist.go: DB mapping methods on SnakeStore and SnakeGameInstance - Chat persistence inlined into c4game handlers - Delete db/persister.go (GamePersister, SnakePersister, ChatPersister) - Stores now take *repository.Queries directly instead of Persister interface
211 lines
4.3 KiB
Go
211 lines
4.3 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.saveSnakeGame(si.game)
|
|
}
|
|
si.gameMu.Unlock()
|
|
si.notify()
|
|
return
|
|
}
|
|
|
|
if si.queries != nil {
|
|
si.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.queries != nil {
|
|
si.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.queries != nil {
|
|
si.saveSnakeGame(si.game)
|
|
}
|
|
|
|
si.gameMu.Unlock()
|
|
si.notify()
|
|
|
|
if gameOver {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|