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/logic.go
Normal file
296
snake/logic.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package snake
|
||||
|
||||
import "math/rand"
|
||||
|
||||
// SpawnSnakes creates snakes for the given active slot indices, evenly spaced
|
||||
// around the grid perimeter facing inward. Returns a length-8 slice where
|
||||
// only active slots have non-nil entries.
|
||||
func SpawnSnakes(activeSlots []int, width, height int) []*Snake {
|
||||
snakes := make([]*Snake, 8)
|
||||
n := len(activeSlots)
|
||||
if n == 0 {
|
||||
return snakes
|
||||
}
|
||||
|
||||
perimeter := 2*(width+height) - 4
|
||||
spacing := perimeter / n
|
||||
|
||||
for i, slot := range activeSlots {
|
||||
pos := (i * spacing) + spacing/2
|
||||
sp := perimeterToSpawn(pos, width, height)
|
||||
|
||||
body := make([]Point, 3)
|
||||
dx, dy := dirDelta(sp.dir)
|
||||
for j := 0; j < 3; j++ {
|
||||
bx := sp.x - dx*j
|
||||
by := sp.y - dy*j
|
||||
// Clamp to grid bounds
|
||||
if bx < 0 {
|
||||
bx = 0
|
||||
} else if bx >= width {
|
||||
bx = width - 1
|
||||
}
|
||||
if by < 0 {
|
||||
by = 0
|
||||
} else if by >= height {
|
||||
by = height - 1
|
||||
}
|
||||
body[j] = Point{X: bx, Y: by}
|
||||
}
|
||||
snakes[slot] = &Snake{
|
||||
Body: body,
|
||||
Dir: sp.dir,
|
||||
Alive: true,
|
||||
Color: slot + 1,
|
||||
}
|
||||
}
|
||||
|
||||
return snakes
|
||||
}
|
||||
|
||||
type spawnInfo struct {
|
||||
x, y int
|
||||
dir Direction
|
||||
}
|
||||
|
||||
// perimeterToSpawn converts a linear position along the perimeter to coordinates
|
||||
// and an inward-facing direction.
|
||||
func perimeterToSpawn(pos, width, height int) spawnInfo {
|
||||
// Top edge: left to right
|
||||
if pos < width {
|
||||
return spawnInfo{pos, 0, DirDown}
|
||||
}
|
||||
pos -= width
|
||||
|
||||
// Right edge: top to bottom
|
||||
if pos < height-1 {
|
||||
return spawnInfo{width - 1, pos + 1, DirLeft}
|
||||
}
|
||||
pos -= height - 1
|
||||
|
||||
// Bottom edge: right to left
|
||||
if pos < width-1 {
|
||||
return spawnInfo{width - 2 - pos, height - 1, DirUp}
|
||||
}
|
||||
pos -= width - 1
|
||||
|
||||
// Left edge: bottom to top
|
||||
return spawnInfo{0, height - 2 - pos, DirRight}
|
||||
}
|
||||
|
||||
func dirDelta(d Direction) (dx, dy int) {
|
||||
switch d {
|
||||
case DirUp:
|
||||
return 0, -1
|
||||
case DirDown:
|
||||
return 0, 1
|
||||
case DirLeft:
|
||||
return -1, 0
|
||||
case DirRight:
|
||||
return 1, 0
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Tick advances all alive snakes one cell in their direction.
|
||||
func Tick(state *GameState) {
|
||||
for _, s := range state.Snakes {
|
||||
if s == nil || !s.Alive {
|
||||
continue
|
||||
}
|
||||
dx, dy := dirDelta(s.Dir)
|
||||
head := s.Body[0]
|
||||
newHead := Point{X: head.X + dx, Y: head.Y + dy}
|
||||
s.Body = append([]Point{newHead}, s.Body...)
|
||||
if s.Growing {
|
||||
s.Growing = false
|
||||
} else {
|
||||
s.Body = s.Body[:len(s.Body)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckFood checks if any snake head is on food. Returns indices of eaten food.
|
||||
func CheckFood(state *GameState) []int {
|
||||
eaten := make(map[int]bool)
|
||||
for _, s := range state.Snakes {
|
||||
if s == nil || !s.Alive {
|
||||
continue
|
||||
}
|
||||
head := s.Body[0]
|
||||
for fi, f := range state.Food {
|
||||
if !eaten[fi] && head.X == f.X && head.Y == f.Y {
|
||||
s.Growing = true
|
||||
eaten[fi] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var indices []int
|
||||
for fi := range eaten {
|
||||
indices = append(indices, fi)
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
// RemoveFood removes food items at the given indices.
|
||||
func RemoveFood(state *GameState, indices []int) {
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
remove := make(map[int]bool, len(indices))
|
||||
for _, i := range indices {
|
||||
remove[i] = true
|
||||
}
|
||||
var remaining []Point
|
||||
for i, f := range state.Food {
|
||||
if !remove[i] {
|
||||
remaining = append(remaining, f)
|
||||
}
|
||||
}
|
||||
state.Food = remaining
|
||||
}
|
||||
|
||||
// SpawnFood adds food items to maintain the target count.
|
||||
func SpawnFood(state *GameState, playerCount int) {
|
||||
target := playerCount/2 + 1
|
||||
for len(state.Food) < target {
|
||||
p := randomEmptyCell(state)
|
||||
if p == nil {
|
||||
break
|
||||
}
|
||||
state.Food = append(state.Food, *p)
|
||||
}
|
||||
}
|
||||
|
||||
func randomEmptyCell(state *GameState) *Point {
|
||||
occupied := make(map[Point]bool)
|
||||
for _, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.Body {
|
||||
occupied[p] = true
|
||||
}
|
||||
}
|
||||
for _, f := range state.Food {
|
||||
occupied[f] = true
|
||||
}
|
||||
|
||||
var empty []Point
|
||||
for y := 0; y < state.Height; y++ {
|
||||
for x := 0; x < state.Width; x++ {
|
||||
p := Point{X: x, Y: y}
|
||||
if !occupied[p] {
|
||||
empty = append(empty, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(empty) == 0 {
|
||||
return nil
|
||||
}
|
||||
choice := empty[rand.Intn(len(empty))]
|
||||
return &choice
|
||||
}
|
||||
|
||||
// CheckCollisions checks for wall, self, and other-snake collisions.
|
||||
// Returns the set of snake indices that died this tick.
|
||||
func CheckCollisions(state *GameState) map[int]bool {
|
||||
dead := make(map[int]bool)
|
||||
|
||||
for i, s := range state.Snakes {
|
||||
if s == nil || !s.Alive {
|
||||
continue
|
||||
}
|
||||
head := s.Body[0]
|
||||
|
||||
// Wall collision
|
||||
if head.X < 0 || head.X >= state.Width || head.Y < 0 || head.Y >= state.Height {
|
||||
dead[i] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Self-body collision (skip head at index 0)
|
||||
for _, bp := range s.Body[1:] {
|
||||
if head.X == bp.X && head.Y == bp.Y {
|
||||
dead[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dead[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Other snake body collision
|
||||
for j, other := range state.Snakes {
|
||||
if i == j || other == nil || !other.Alive {
|
||||
continue
|
||||
}
|
||||
for _, bp := range other.Body[1:] {
|
||||
if head.X == bp.X && head.Y == bp.Y {
|
||||
dead[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dead[i] {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Head-to-head collision
|
||||
for i, s := range state.Snakes {
|
||||
if s == nil || !s.Alive || dead[i] {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(state.Snakes); j++ {
|
||||
other := state.Snakes[j]
|
||||
if other == nil || !other.Alive || dead[j] {
|
||||
continue
|
||||
}
|
||||
if s.Body[0].X == other.Body[0].X && s.Body[0].Y == other.Body[0].Y {
|
||||
dead[i] = true
|
||||
dead[j] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dead
|
||||
}
|
||||
|
||||
// MarkDead sets Alive=false for snakes in the dead set.
|
||||
func MarkDead(state *GameState, dead map[int]bool) {
|
||||
for i := range dead {
|
||||
state.Snakes[i].Alive = false
|
||||
}
|
||||
}
|
||||
|
||||
// AliveCount returns the number of living snakes.
|
||||
func AliveCount(state *GameState) int {
|
||||
count := 0
|
||||
for _, s := range state.Snakes {
|
||||
if s != nil && s.Alive {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// LastAlive returns the index of the last alive snake, or -1 if none or multiple.
|
||||
func LastAlive(state *GameState) int {
|
||||
idx := -1
|
||||
for i, s := range state.Snakes {
|
||||
if s != nil && s.Alive {
|
||||
if idx != -1 {
|
||||
return -1
|
||||
}
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// ValidateDirection returns true if the new direction is not a 180 reversal.
|
||||
func ValidateDirection(current, proposed Direction) bool {
|
||||
return !current.Opposite(proposed)
|
||||
}
|
||||
167
snake/loop.go
Normal file
167
snake/loop.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tickInterval = 500 * time.Millisecond
|
||||
countdownSeconds = 10
|
||||
inactivityLimit = 60 * time.Second
|
||||
)
|
||||
|
||||
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
||||
si.game.Status = StatusCountdown
|
||||
si.game.CountdownEnd = time.Now().Add(countdownSeconds * 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))
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) gamePhase() {
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastInput := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-si.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
si.gameMu.Lock()
|
||||
|
||||
if si.game.Status != StatusInProgress {
|
||||
si.gameMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Apply pending directions (iterate all 8 slots)
|
||||
inputReceived := false
|
||||
for i := 0; i < 8; i++ {
|
||||
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
|
||||
si.game.State.Snakes[i].Dir = *si.pendingDir[i]
|
||||
si.pendingDir[i] = nil
|
||||
inputReceived = true
|
||||
}
|
||||
}
|
||||
if inputReceived {
|
||||
lastInput = time.Now()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
// Check game over
|
||||
alive := AliveCount(state)
|
||||
if alive <= 1 {
|
||||
si.game.Status = StatusFinished
|
||||
winnerIdx := LastAlive(state)
|
||||
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
|
||||
si.game.Winner = si.game.Players[winnerIdx]
|
||||
}
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
|
||||
if alive <= 1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
148
snake/types.go
Normal file
148
snake/types.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
DirUp Direction = iota
|
||||
DirDown
|
||||
DirLeft
|
||||
DirRight
|
||||
)
|
||||
|
||||
// Opposite returns true if a and b are 180-degree reversals.
|
||||
func (d Direction) Opposite(other Direction) bool {
|
||||
switch d {
|
||||
case DirUp:
|
||||
return other == DirDown
|
||||
case DirDown:
|
||||
return other == DirUp
|
||||
case DirLeft:
|
||||
return other == DirRight
|
||||
case DirRight:
|
||||
return other == DirLeft
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
type Snake struct {
|
||||
Body []Point `json:"body"`
|
||||
Dir Direction `json:"dir"`
|
||||
Alive bool `json:"alive"`
|
||||
Growing bool `json:"growing"`
|
||||
Color int `json:"color"` // 1-8
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Snakes []*Snake `json:"snakes"`
|
||||
Food []Point `json:"food"`
|
||||
}
|
||||
|
||||
func (gs *GameState) ToJSON() string {
|
||||
data, _ := json.Marshal(gs)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func GameStateFromJSON(data string) (*GameState, error) {
|
||||
var gs GameState
|
||||
if err := json.Unmarshal([]byte(data), &gs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gs, nil
|
||||
}
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusWaitingForPlayers Status = iota
|
||||
StatusCountdown
|
||||
StatusInProgress
|
||||
StatusFinished
|
||||
)
|
||||
|
||||
type PlayerID string
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
UserID *string
|
||||
Nickname string
|
||||
Slot int // 0-7
|
||||
}
|
||||
|
||||
type SnakeGame struct {
|
||||
ID string
|
||||
State *GameState
|
||||
Players []*Player // up to 8
|
||||
Status Status
|
||||
Winner *Player // nil if draw
|
||||
CountdownEnd time.Time // when countdown reaches 0
|
||||
RematchGameID *string
|
||||
}
|
||||
|
||||
func (sg *SnakeGame) IsFinished() bool {
|
||||
return sg.Status == StatusFinished
|
||||
}
|
||||
|
||||
func (sg *SnakeGame) PlayerCount() int {
|
||||
count := 0
|
||||
for _, p := range sg.Players {
|
||||
if p != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Grid presets
|
||||
type GridPreset struct {
|
||||
Name string
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
var GridPresets = []GridPreset{
|
||||
{Name: "Small", Width: 20, Height: 20},
|
||||
{Name: "Medium", Width: 30, Height: 20},
|
||||
{Name: "Large", Width: 40, Height: 20},
|
||||
}
|
||||
|
||||
// snapshot returns a shallow copy of the game safe for reading outside the lock.
|
||||
// Slices and pointers are shared but the top-level struct is copied.
|
||||
func (sg *SnakeGame) snapshot() *SnakeGame {
|
||||
cp := *sg
|
||||
if sg.State != nil {
|
||||
stateCp := *sg.State
|
||||
// Copy slices so the caller's iteration is safe
|
||||
stateCp.Snakes = make([]*Snake, len(sg.State.Snakes))
|
||||
copy(stateCp.Snakes, sg.State.Snakes)
|
||||
stateCp.Food = make([]Point, len(sg.State.Food))
|
||||
copy(stateCp.Food, sg.State.Food)
|
||||
cp.State = &stateCp
|
||||
}
|
||||
cp.Players = make([]*Player, len(sg.Players))
|
||||
copy(cp.Players, sg.Players)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Snake colors (hex values for CSS)
|
||||
var SnakeColors = []string{
|
||||
"#00b894", // 1: Green
|
||||
"#e17055", // 2: Orange
|
||||
"#0984e3", // 3: Blue
|
||||
"#6c5ce7", // 4: Purple
|
||||
"#fd79a8", // 5: Pink
|
||||
"#00cec9", // 6: Cyan
|
||||
"#d63031", // 7: Red
|
||||
"#fdcb6e", // 8: Yellow
|
||||
}
|
||||
Reference in New Issue
Block a user