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)
|
||||
}
|
||||
Reference in New Issue
Block a user