- Add brotli compression (level 5) to long-lived SSE event streams (HandleGameEvents, HandleSnakeEvents) to reduce wire payload - Fix all errcheck violations with nolint annotations for best-effort calls - Fix goimports: separate stdlib, third-party, and local import groups - Fix staticcheck: add package comments, use tagged switch - Zero lint issues remaining
302 lines
6.2 KiB
Go
302 lines
6.2 KiB
Go
// Package snake implements snake game logic, state management, and persistence.
|
|
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.
|
|
// Single player always maintains exactly 1 food for classic snake feel.
|
|
func SpawnFood(state *GameState, playerCount int, mode GameMode) {
|
|
target := playerCount/2 + 1
|
|
if mode == ModeSinglePlayer {
|
|
target = 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)
|
|
}
|