223 lines
4.6 KiB
Go
223 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const (
|
|
gridWidth = 40
|
|
gridHeight = 20
|
|
)
|
|
|
|
type point struct {
|
|
x, y int
|
|
}
|
|
|
|
type snakeDirection int
|
|
|
|
const (
|
|
dirUp snakeDirection = iota
|
|
dirDown
|
|
dirLeft
|
|
dirRight
|
|
)
|
|
|
|
type snakeTickMsg time.Time
|
|
|
|
type SnakeModel struct {
|
|
snake []point
|
|
dir snakeDirection
|
|
nextDir snakeDirection
|
|
food point
|
|
score int
|
|
gameOver bool
|
|
rng *rand.Rand
|
|
}
|
|
|
|
func newSnakeModel() SnakeModel {
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
snake := []point{
|
|
{gridWidth/2 + 1, gridHeight / 2},
|
|
{gridWidth / 2, gridHeight / 2},
|
|
{gridWidth/2 - 1, gridHeight / 2},
|
|
}
|
|
m := SnakeModel{
|
|
snake: snake,
|
|
dir: dirRight,
|
|
nextDir: dirRight,
|
|
rng: rng,
|
|
}
|
|
m.food = m.spawnFood()
|
|
return m
|
|
}
|
|
|
|
func (m SnakeModel) spawnFood() point {
|
|
occupied := make(map[point]bool, len(m.snake))
|
|
for _, s := range m.snake {
|
|
occupied[s] = true
|
|
}
|
|
for {
|
|
f := point{m.rng.Intn(gridWidth), m.rng.Intn(gridHeight)}
|
|
if !occupied[f] {
|
|
return f
|
|
}
|
|
}
|
|
}
|
|
|
|
func snakeTickCmd() tea.Cmd {
|
|
return tea.Tick(130*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return snakeTickMsg(t)
|
|
})
|
|
}
|
|
|
|
func (m SnakeModel) Init() tea.Cmd {
|
|
return snakeTickCmd()
|
|
}
|
|
|
|
func (m SnakeModel) Update(msg tea.Msg) (SnakeModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.dir != dirDown {
|
|
m.nextDir = dirUp
|
|
}
|
|
case "down", "j":
|
|
if m.dir != dirUp {
|
|
m.nextDir = dirDown
|
|
}
|
|
case "left", "h":
|
|
if m.dir != dirRight {
|
|
m.nextDir = dirLeft
|
|
}
|
|
case "right", "l":
|
|
if m.dir != dirLeft {
|
|
m.nextDir = dirRight
|
|
}
|
|
case "r":
|
|
if m.gameOver {
|
|
fresh := newSnakeModel()
|
|
return fresh, fresh.Init()
|
|
}
|
|
}
|
|
|
|
case snakeTickMsg:
|
|
if m.gameOver {
|
|
return m, nil
|
|
}
|
|
|
|
m.dir = m.nextDir
|
|
head := m.snake[0]
|
|
|
|
var next point
|
|
switch m.dir {
|
|
case dirUp:
|
|
next = point{head.x, head.y - 1}
|
|
case dirDown:
|
|
next = point{head.x, head.y + 1}
|
|
case dirLeft:
|
|
next = point{head.x - 1, head.y}
|
|
case dirRight:
|
|
next = point{head.x + 1, head.y}
|
|
}
|
|
|
|
if next.x < 0 || next.x >= gridWidth || next.y < 0 || next.y >= gridHeight {
|
|
m.gameOver = true
|
|
return m, nil
|
|
}
|
|
|
|
for _, s := range m.snake {
|
|
if s == next {
|
|
m.gameOver = true
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if next == m.food {
|
|
m.snake = append([]point{next}, m.snake...)
|
|
m.score++
|
|
m.food = m.spawnFood()
|
|
} else {
|
|
m.snake = append([]point{next}, m.snake[:len(m.snake)-1]...)
|
|
}
|
|
|
|
return m, snakeTickCmd()
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
|
|
|
|
//TODO test if the characters are rendered on different terminals
|
|
func (m SnakeModel) View() string {
|
|
|
|
snakeIndex := make(map[point]int, len(m.snake))
|
|
for i, s := range m.snake {
|
|
snakeIndex[s] = i
|
|
}
|
|
|
|
//TODO move to top
|
|
borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8A1D7D"))
|
|
headStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4ADE80")).Bold(true)
|
|
bodyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#16A34A"))
|
|
foodStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FB923C"))
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E2E8F0"))
|
|
scoreStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#94A3B8"))
|
|
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#64748B"))
|
|
gameOverStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#EF4444"))
|
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(
|
|
titleStyle.Render(" SNAKE") +
|
|
" " +
|
|
scoreStyle.Render(fmt.Sprintf("Score: %d", m.score)) +
|
|
"\n",
|
|
)
|
|
|
|
sb.WriteString(borderStyle.Render("┌"+strings.Repeat("─", gridWidth)+"┐") + "\n")
|
|
|
|
for y := 0; y < gridHeight; y++ {
|
|
sb.WriteString(borderStyle.Render("│"))
|
|
for x := 0; x < gridWidth; x++ {
|
|
p := point{x, y}
|
|
if idx, isSnake := snakeIndex[p]; isSnake {
|
|
if idx == 0 {
|
|
sb.WriteString(headStyle.Render("■"))
|
|
} else {
|
|
sb.WriteString(bodyStyle.Render("□"))
|
|
}
|
|
} else if p == m.food {
|
|
sb.WriteString(foodStyle.Render("●"))
|
|
} else {
|
|
sb.WriteString(" ")
|
|
}
|
|
}
|
|
sb.WriteString(borderStyle.Render("│") + "\n")
|
|
}
|
|
|
|
sb.WriteString(borderStyle.Render("└"+strings.Repeat("─", gridWidth)+"┘") + "\n")
|
|
|
|
if m.gameOver {
|
|
sb.WriteString(
|
|
gameOverStyle.Render(" GAME OVER") +
|
|
" " +
|
|
scoreStyle.Render(fmt.Sprintf("Final score: %d", m.score)) +
|
|
"\n",
|
|
)
|
|
sb.WriteString(footerStyle.Render(" r to restart · b to go back · q to quit") + "\n")
|
|
} else {
|
|
sb.WriteString(footerStyle.Render(" ↑↓←→ / hjkl to move · b to go back · q to quit") + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|