WIP: Add multiplayer Snake game
N-player (2-8) real-time Snake game alongside Connect 4. Lobby has tabs to switch between games. Players join via invite link with 10-second countdown. Game loop runs at tick-based intervals with NATS pub/sub for state sync. Keyboard input not yet working (Datastar keydown binding issue still under investigation).
This commit is contained in:
296
snake/store.go
Normal file
296
snake/store.go
Normal file
@@ -0,0 +1,296 @@
|
||||
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) *SnakeGameInstance {
|
||||
id := generateID(4)
|
||||
sg := &SnakeGame{
|
||||
ID: id,
|
||||
State: &GameState{
|
||||
Width: width,
|
||||
Height: height,
|
||||
},
|
||||
Players: make([]*Player, 8),
|
||||
Status: StatusWaitingForPlayers,
|
||||
}
|
||||
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 games that can be joined.
|
||||
// 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.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()
|
||||
|
||||
if 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
|
||||
si.gameMu.Unlock()
|
||||
|
||||
newSI := si.store.Create(width, height)
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user