A Snake clone I made for fun. Code is a mess.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
worm/main.go

409 lines
7.7 KiB

package main
import (
"fmt"
"image/color"
"log"
"math/rand"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
)
var (
mplusNormalFont font.Face
)
func init() {
// TODO: don't use the ebiten example code font
tt, err := opentype.Parse(fonts.MPlus1pRegular_ttf)
if err != nil {
log.Fatal(err)
}
const dpi = 72
mplusNormalFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
Size: 24,
DPI: dpi,
Hinting: font.HintingFull,
})
if err != nil {
log.Fatal(err)
}
rand.Seed(time.Now().UnixMicro())
}
type Kind uint8
const (
Empty Kind = iota
Worm
Food
)
func (k Kind) String() string {
switch k {
case Empty:
return "Empty"
case Worm:
return "Worm"
case Food:
return "Food"
default:
return "<unknown>"
}
}
type Direction uint8
const (
None Direction = iota
North
South
East
West
)
func (d Direction) String() string {
switch d {
case None:
return "None"
case North:
return "North"
case South:
return "South"
case East:
return "East"
case West:
return "West"
default:
return "<unknown>"
}
}
type Tile struct {
kind Kind
dir Direction
}
func (t Tile) String() string {
return fmt.Sprintf("%s -- %s", t.kind, t.dir)
}
var EmptyTile = Tile{kind: Empty, dir: None}
var FoodTile = Tile{kind: Food, dir: None}
type point struct {
x, y int
}
type WormGame struct {
screenWidth, screenHeight int
width, height int
initLength int
score int
won, lost bool
board [][]Tile
head point
tail point
lastMove time.Time
speed time.Duration
}
func NewGame(screenWidth, screenHeight int, initLength int, speed time.Duration) *WormGame {
w := &WormGame{
screenWidth: screenWidth,
screenHeight: screenHeight,
width: screenWidth / 4,
height: screenHeight / 4,
initLength: initLength,
speed: speed,
}
w.Reset()
return w
}
func (w *WormGame) Reset() {
w.lost = false
w.won = false
w.score = 0
w.board = make([][]Tile, w.width)
for idx := range w.board {
w.board[idx] = make([]Tile, w.height)
}
w.head = point{
x: w.width / 2,
y: w.height / 2,
}
w.Set(w.head, Worm, East)
for idx := 1; idx < w.initLength; idx++ {
w.board[w.head.x-idx][w.head.y] = Tile{kind: Worm, dir: East}
}
w.tail = point{w.head.x - w.initLength + 1, w.head.y}
w.RandomizeFood()
w.lastMove = time.Now()
}
func (w *WormGame) randomFreeSpace() *point {
checked := map[point]struct{}{}
for len(checked) <= w.width*w.height {
x := rand.Intn(w.width)
y := rand.Intn(w.height)
p := point{x, y}
if _, ok := checked[p]; ok {
continue
}
checked[p] = struct{}{}
if w.board[x][y].kind == Empty {
return &p
}
}
return nil
}
func (w *WormGame) RandomizeFood() {
p := w.randomFreeSpace()
if p == nil {
w.won = true
return
}
w.board[p.x][p.y] = FoodTile
}
func (w *WormGame) InBounds(p point) bool {
return p.x < w.width && p.x >= 0 && p.y < w.height && p.y >= 0
}
func (w *WormGame) MustInBounds(p point) {
if !w.InBounds(p) {
panic("out of bounds!")
}
}
func (w *WormGame) Get(p point) Tile {
w.MustInBounds(p)
return w.board[p.x][p.y]
}
func (w *WormGame) Set(p point, kind Kind, dir Direction) {
w.MustInBounds(p)
w.board[p.x][p.y] = Tile{kind: kind, dir: dir}
}
func (w *WormGame) SetEmpty(p point) {
w.MustInBounds(p)
w.board[p.x][p.y] = EmptyTile
}
func (w *WormGame) AdvanceWorm() {
dir := w.Get(w.head).dir
prev := w.head
switch dir {
case North:
if w.head.y-1 > w.height {
panic("out of bounds")
}
w.head.y -= 1
case South:
if w.head.y+1 < 0 {
panic("out of bounds")
}
w.head.y += 1
case East:
if w.head.x+1 > w.width {
panic("out of bounds")
}
w.head.x += 1
case West:
if w.head.x-1 < 0 {
panic("out of bounds")
}
w.head.x -= 1
}
// change direction of prev worm segment
w.Set(prev, Worm, dir)
// add new worm segment
w.Set(w.head, Worm, dir)
tailDir := w.Get(w.tail).dir
// shorten tail
w.SetEmpty(w.tail)
// update tail pointer
switch tailDir {
case North:
w.tail = point{w.tail.x, w.tail.y - 1}
case South:
w.tail = point{w.tail.x, w.tail.y + 1}
case East:
w.tail = point{w.tail.x + 1, w.tail.y}
case West:
w.tail = point{w.tail.x - 1, w.tail.y}
}
// update laste move ts
w.lastMove = time.Now()
}
func (w *WormGame) EmbiggenWorm() {
tailDir := w.Get(w.tail).dir
// update tail pointer
switch tailDir {
case North:
w.tail = point{w.tail.x, w.tail.y + 1}
case South:
w.tail = point{w.tail.x, w.tail.y - 1}
case East:
w.tail = point{w.tail.x - 1, w.tail.y}
case West:
w.tail = point{w.tail.x + 1, w.tail.y}
}
w.Set(w.tail, Worm, tailDir)
}
func (w *WormGame) Update() error {
if w.won || w.lost {
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
w.Reset()
}
return nil
}
up := inpututil.IsKeyJustPressed(ebiten.KeyArrowUp)
down := inpututil.IsKeyJustPressed(ebiten.KeyArrowDown)
left := inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft)
right := inpututil.IsKeyJustPressed(ebiten.KeyArrowRight)
// cancel out simultaneous up and down
if up && down {
up = false
down = false
}
// cancel up simultaneous left and right,
// also up/down take priority over left/right
if (left && right) || (up || down) {
left = false
right = false
}
changedDirection := true
headTile := w.Get(w.head)
if up && headTile.dir != South {
headTile.dir = North
} else if down && headTile.dir != North {
headTile.dir = South
} else if left && headTile.dir != East {
headTile.dir = West
} else if right && headTile.dir != West {
headTile.dir = East
} else {
changedDirection = false
}
w.Set(w.head, headTile.kind, headTile.dir)
var next point
switch headTile.dir {
case North:
next = point{w.head.x, w.head.y - 1}
case South:
next = point{w.head.x, w.head.y + 1}
case East:
next = point{w.head.x + 1, w.head.y}
case West:
next = point{w.head.x - 1, w.head.y}
}
// don't evaluate space or advance if we didn't change direction and haven't reached timeout
if !changedDirection && time.Now().Sub(w.lastMove) < w.speed {
return nil
}
var ate bool
if next.x >= w.width || next.y >= w.height || next.x < 0 || next.y < 0 {
w.lost = true
} else {
tile := w.board[next.x][next.y]
ate = (tile.kind == Food)
w.lost = (tile.kind == Worm)
}
if w.lost {
return nil
}
if ate {
w.score += 1
w.EmbiggenWorm()
w.RandomizeFood()
}
// check if we won (all spaces are worm) and skip moving (since it would be
// an auto-loss) this is probably close to impossible?
if w.won {
return nil
}
w.AdvanceWorm()
return nil
}
func (w *WormGame) Draw(screen *ebiten.Image) {
screen.Clear()
for col := range w.board {
for row := range w.board[col] {
x := float64(col * 4)
y := float64(row * 4)
switch w.board[col][row].kind {
case Empty:
ebitenutil.DrawRect(screen, x, y, 4, 4, color.Black)
case Worm:
ebitenutil.DrawRect(screen, x, y, 4, 4, color.White)
case Food:
ebitenutil.DrawRect(screen, x, y, 4, 4, color.White)
}
}
}
if w.won {
text.Draw(screen, fmt.Sprintf("You Win! Score: %d", w.score), mplusNormalFont, 20, w.screenHeight/2, color.White)
return
} else if w.lost {
text.Draw(screen, fmt.Sprintf("Game Over. Score: %d", w.score), mplusNormalFont, 20, w.screenHeight/2, color.White)
return
}
}
func (w *WormGame) Layout(outsideWidth, outsideHeight int) (int, int) {
return w.screenWidth, w.screenHeight
}
const (
screenWidth = 320
screenHeight = 240
)
func main() {
game := NewGame(screenWidth, screenHeight, 4, 75*time.Millisecond)
ebiten.SetWindowSize(screenWidth*2, screenHeight*2)
ebiten.SetWindowTitle("Worm")
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}