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
302 lines
6.0 KiB
Go
302 lines
6.0 KiB
Go
package snake
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"sync"
|
|
)
|
|
|
|
type PubSub interface {
|
|
Publish(subject string, data []byte) error
|
|
}
|
|
|
|
type Persister interface {
|
|
SaveSnakeGame(sg *SnakeGame) error
|
|
LoadSnakeGame(id string) (*SnakeGame, error)
|
|
SaveSnakePlayer(gameID string, player *Player) error
|
|
LoadSnakePlayers(gameID string) ([]*Player, error)
|
|
DeleteSnakeGame(id string) error
|
|
}
|
|
|
|
type SnakeStore struct {
|
|
games map[string]*SnakeGameInstance
|
|
gamesMu sync.RWMutex
|
|
persister Persister
|
|
pubsub PubSub
|
|
}
|
|
|
|
func NewSnakeStore() *SnakeStore {
|
|
return &SnakeStore{
|
|
games: make(map[string]*SnakeGameInstance),
|
|
}
|
|
}
|
|
|
|
func (ss *SnakeStore) SetPersister(p Persister) {
|
|
ss.persister = p
|
|
}
|
|
|
|
func (ss *SnakeStore) SetPubSub(ps PubSub) {
|
|
ss.pubsub = ps
|
|
}
|
|
|
|
func (ss *SnakeStore) makeNotify(gameID string) func() {
|
|
return func() {
|
|
if ss.pubsub != nil {
|
|
ss.pubsub.Publish("snake."+gameID, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstance {
|
|
id := generateID(4)
|
|
sg := &SnakeGame{
|
|
ID: id,
|
|
State: &GameState{
|
|
Width: width,
|
|
Height: height,
|
|
},
|
|
Players: make([]*Player, 8),
|
|
Status: StatusWaitingForPlayers,
|
|
Mode: mode,
|
|
}
|
|
si := &SnakeGameInstance{
|
|
game: sg,
|
|
notify: ss.makeNotify(id),
|
|
persister: ss.persister,
|
|
store: ss,
|
|
}
|
|
|
|
ss.gamesMu.Lock()
|
|
ss.games[id] = si
|
|
ss.gamesMu.Unlock()
|
|
|
|
if ss.persister != nil {
|
|
ss.persister.SaveSnakeGame(sg)
|
|
}
|
|
|
|
return si
|
|
}
|
|
|
|
func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
|
ss.gamesMu.RLock()
|
|
si, ok := ss.games[id]
|
|
ss.gamesMu.RUnlock()
|
|
|
|
if ok {
|
|
return si, true
|
|
}
|
|
|
|
if ss.persister == nil {
|
|
return nil, false
|
|
}
|
|
|
|
sg, err := ss.persister.LoadSnakeGame(id)
|
|
if err != nil || sg == nil {
|
|
return nil, false
|
|
}
|
|
|
|
players, _ := ss.persister.LoadSnakePlayers(id)
|
|
if sg.Players == nil {
|
|
sg.Players = make([]*Player, 8)
|
|
}
|
|
for _, p := range players {
|
|
if p.Slot >= 0 && p.Slot < 8 {
|
|
sg.Players[p.Slot] = p
|
|
}
|
|
}
|
|
|
|
si = &SnakeGameInstance{
|
|
game: sg,
|
|
notify: ss.makeNotify(id),
|
|
persister: ss.persister,
|
|
store: ss,
|
|
}
|
|
|
|
ss.gamesMu.Lock()
|
|
ss.games[id] = si
|
|
ss.gamesMu.Unlock()
|
|
|
|
return si, true
|
|
}
|
|
|
|
func (ss *SnakeStore) Delete(id string) error {
|
|
ss.gamesMu.Lock()
|
|
si, ok := ss.games[id]
|
|
delete(ss.games, id)
|
|
ss.gamesMu.Unlock()
|
|
|
|
if ok && si != nil {
|
|
si.Stop()
|
|
}
|
|
|
|
if ss.persister != nil {
|
|
return ss.persister.DeleteSnakeGame(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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))
|
|
for _, si := range ss.games {
|
|
instances = append(instances, si)
|
|
}
|
|
ss.gamesMu.RUnlock()
|
|
|
|
var games []*SnakeGame
|
|
for _, si := range instances {
|
|
si.gameMu.RLock()
|
|
g := si.game
|
|
if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) {
|
|
games = append(games, g)
|
|
}
|
|
si.gameMu.RUnlock()
|
|
}
|
|
return games
|
|
}
|
|
|
|
type SnakeGameInstance struct {
|
|
game *SnakeGame
|
|
gameMu sync.RWMutex
|
|
pendingDir [8]*Direction
|
|
notify func()
|
|
persister Persister
|
|
store *SnakeStore
|
|
stopCh chan struct{}
|
|
loopOnce sync.Once
|
|
}
|
|
|
|
func (si *SnakeGameInstance) ID() string {
|
|
si.gameMu.RLock()
|
|
defer si.gameMu.RUnlock()
|
|
return si.game.ID
|
|
}
|
|
|
|
// GetGame returns a snapshot of the game state safe for concurrent read.
|
|
func (si *SnakeGameInstance) GetGame() *SnakeGame {
|
|
si.gameMu.RLock()
|
|
defer si.gameMu.RUnlock()
|
|
return si.game.snapshot()
|
|
}
|
|
|
|
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
|
|
si.gameMu.RLock()
|
|
defer si.gameMu.RUnlock()
|
|
for i, p := range si.game.Players {
|
|
if p != nil && p.ID == pid {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (si *SnakeGameInstance) Join(player *Player) bool {
|
|
si.gameMu.Lock()
|
|
defer si.gameMu.Unlock()
|
|
|
|
if si.game.Status == StatusInProgress || si.game.Status == StatusFinished {
|
|
return false
|
|
}
|
|
|
|
slot := -1
|
|
for i, p := range si.game.Players {
|
|
if p == nil {
|
|
slot = i
|
|
break
|
|
}
|
|
}
|
|
if slot == -1 {
|
|
return false
|
|
}
|
|
|
|
player.Slot = slot
|
|
si.game.Players[slot] = player
|
|
|
|
if si.persister != nil {
|
|
si.persister.SaveSnakePlayer(si.game.ID, player)
|
|
si.persister.SaveSnakeGame(si.game)
|
|
}
|
|
|
|
si.notify()
|
|
|
|
// 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()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// SetDirection buffers a direction change for the given slot.
|
|
// The write happens under the game lock to avoid a data race with the game loop.
|
|
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
|
if slot < 0 || slot >= 8 {
|
|
return
|
|
}
|
|
si.gameMu.Lock()
|
|
defer si.gameMu.Unlock()
|
|
|
|
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
|
|
}
|
|
}
|
|
si.pendingDir[slot] = &dir
|
|
}
|
|
|
|
func (si *SnakeGameInstance) Stop() {
|
|
if si.stopCh != nil {
|
|
select {
|
|
case <-si.stopCh:
|
|
default:
|
|
close(si.stopCh)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
|
si.gameMu.Lock()
|
|
|
|
if !si.game.IsFinished() || si.game.RematchGameID != nil {
|
|
si.gameMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Capture state needed, then release lock before calling store.Create
|
|
// (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, mode)
|
|
newID := newSI.ID()
|
|
|
|
si.gameMu.Lock()
|
|
// Re-check after reacquiring lock
|
|
if si.game.RematchGameID != nil {
|
|
si.gameMu.Unlock()
|
|
return newSI
|
|
}
|
|
si.game.RematchGameID = &newID
|
|
|
|
if si.persister != nil {
|
|
si.persister.SaveSnakeGame(si.game)
|
|
}
|
|
si.gameMu.Unlock()
|
|
|
|
si.notify()
|
|
return newSI
|
|
}
|
|
|
|
func generateID(size int) string {
|
|
b := make([]byte, size)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|