This repository is learn how to use ebiten to build a snake game
package internal
import (
"image/color"
"math/rand"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
var (
dirUp = Point{x: 0, y: -1}
dirDown = Point{x: 0, y: 1}
dirLeft = Point{x: -1, y: 0}
dirRight = Point{x: 1, y: 0}
MplusFaceSource *text.GoTextFaceSource
)
const (
gameSpeed = time.Second / 6
ScreenWidth = 640
ScreenHeight = 480
gridSize = 20
)
type Point struct {
x, y int
}
type Game struct {
snake []Point
direction Point
lastUpdate time.Time
food Point
randGenerator *rand.Rand
gameOver bool
}
func (g *Game) Update() error {
if g.gameOver {
return nil
}
// handle key
if ebiten.IsKeyPressed(ebiten.KeyW) {
g.direction = dirUp
} else if ebiten.IsKeyPressed(ebiten.KeyS) {
g.direction = dirDown
} else if ebiten.IsKeyPressed(ebiten.KeyA) {
g.direction = dirLeft
} else if ebiten.IsKeyPressed(ebiten.KeyD) {
g.direction = dirRight
}
// slow down
if time.Since(g.lastUpdate) < gameSpeed {
return nil
}
g.lastUpdate = time.Now()
g.updateSnake(&g.snake, g.direction)
return nil
}
// isBadCollision - check if snake is collision
func (g Game) isBadCollision(
newHead Point,
snake []Point,
) bool {
// check if out of bound
if newHead.x < 0 || newHead.y < 0 ||
newHead.x >= ScreenWidth/gridSize || newHead.y >= ScreenHeight/gridSize {
return true
}
// is newhead collision
for _, snakeBody := range snake {
if snakeBody == newHead {
return true
}
}
return false
}
// updateSnake - update snake with direction
func (g *Game) updateSnake(snake *[]Point, direction Point) {
head := (*snake)[0]
newHead := Point{
x: head.x + direction.x,
y: head.y + direction.y,
}
// check collision for snake
if g.isBadCollision(newHead, *snake) {
g.gameOver = true
return
}
// check collision with food
if newHead == g.food {
*snake = append(
[]Point{newHead},
*snake...,
)
g.SpawnFood()
} else {
*snake = append(
[]Point{newHead},
(*snake)[:len(*snake)-1]...,
)
}
}
// drawGameOverText - draw game over text on screen
func (g *Game) drawGameOverText(screen *ebiten.Image) {
face := &text.GoTextFace{
Source: MplusFaceSource,
Size: 48,
}
title := "Game Over!"
w, h := text.Measure(title,
face,
face.Size,
)
op := &text.DrawOptions{}
op.GeoM.Translate(ScreenWidth/2-w/2, ScreenHeight/2-h/2)
op.ColorScale.ScaleWithColor(color.White)
text.Draw(
screen,
title,
face,
op,
)
}
// Draw - handle screen update
func (g *Game) Draw(screen *ebiten.Image) {
for _, p := range g.snake {
vector.DrawFilledRect(
screen,
float32(p.x*gridSize),
float32(p.y*gridSize),
gridSize,
gridSize,
color.White,
true,
)
}
vector.DrawFilledRect(
screen,
float32(g.food.x*gridSize),
float32(g.food.y*gridSize),
gridSize,
gridSize,
color.RGBA{255, 0, 0, 255},
true,
)
if g.gameOver {
g.drawGameOverText(screen)
}
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return ScreenWidth, ScreenHeight
}
// SpawnFood - generate new food
func (g *Game) SpawnFood() {
g.food = Point{
x: g.randGenerator.Intn(ScreenWidth / gridSize),
y: g.randGenerator.Intn(ScreenHeight / gridSize),
}
}
// NewGame - create Game
func NewGame() *Game {
return &Game{
snake: []Point{{
x: ScreenWidth / gridSize / 2,
y: ScreenHeight / gridSize / 2,
}},
direction: Point{x: 1, y: 0},
randGenerator: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
const (
GameSpeed = time.Second / 6
ScreenWidth = 640
ScreenHeight = 480
GridSize = 20
)
package entity
import "github.com/hajimehoshi/ebiten/v2"
type Entity interface {
Update(world worldView) bool
Draw(screen *ebiten.Image)
Tag() string
}
var _ Entity = (*Food)(nil)
type Food struct {
position math.Point
randGenerator *rand.Rand
}
func NewFood(randGenerator *rand.Rand) *Food {
return &Food{
position: math.RandomPosition(randGenerator),
randGenerator: randGenerator,
}
}
func (f *Food) Update(world worldView) bool {
return false
}
func (f *Food) Draw(screen *ebiten.Image) {
vector.DrawFilledRect(
screen,
float32(f.position.X*common.GridSize),
float32(f.position.Y*common.GridSize),
float32(common.GridSize),
float32(common.GridSize),
color.RGBA{255, 0, 0, 255},
true,
)
}
func (f *Food) Tag() string {
return "food"
}
func (f *Food) Respawn() {
f.position = math.RandomPosition(f.randGenerator)
}
package entity
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/common"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/math"
)
var _ Entity = (*Player)(nil)
type Player struct {
body []math.Point
direction math.Point
}
func NewPlayer(start, dir math.Point) *Player {
return &Player{
body: []math.Point{start},
direction: dir,
}
}
func (p *Player) Update(worldView worldView) bool {
newHead := p.body[0].Add(p.direction)
// check collision for snake
if newHead.IsBadCollision(p.body) {
return true
}
grow := false
for _, entity := range worldView.GetEntities("food") {
food := entity.(*Food)
// check collision
if newHead.Equals(food.position) {
grow = true
food.Respawn()
break
}
}
if grow {
p.body = append(
[]math.Point{newHead},
p.body...,
)
} else {
p.body = append(
[]math.Point{newHead},
p.body[:len(p.body)-1]...,
)
}
return false
}
func (p *Player) Draw(screen *ebiten.Image) {
for _, pt := range p.body {
vector.DrawFilledRect(
screen,
float32(pt.X*common.GridSize),
float32(pt.Y*common.GridSize),
float32(common.GridSize),
float32(common.GridSize),
color.White,
true,
)
}
}
func (p *Player) SetDirection(dir math.Point) {
p.direction = dir
}
func (p Player) Tag() string {
return "player"
}
package entity
type worldView interface {
GetEntities(tag string) []Entity
}
package game
import "github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/entity"
type World struct {
entities []entity.Entity
}
func NewWorld() *World {
return &World{
entities: []entity.Entity{},
}
}
func (w *World) AddEntity(entity entity.Entity) {
w.entities = append(w.entities, entity)
}
func (w *World) Entities() []entity.Entity {
return w.entities
}
func (w World) GetEntities(tag string) []entity.Entity {
var result []entity.Entity
for _, e := range w.entities {
if e.Tag() == tag {
result = append(result, e)
}
}
return result
}
package math
import (
"math/rand"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/common"
)
type Point struct {
X, Y int
}
var (
DirUp = Point{X: 0, Y: -1}
DirDown = Point{X: 0, Y: 1}
DirLeft = Point{X: -1, Y: 0}
DirRight = Point{X: 1, Y: 0}
)
func (p Point) Equals(other Point) bool {
return p.X == other.X && p.Y == other.Y
}
func (p Point) Add(other Point) Point {
return Point{
X: p.X + other.X,
Y: p.Y + other.Y,
}
}
// IsBadCollision - check if snake is collision
func (p Point) IsBadCollision(
points []Point,
) bool {
// check if out of bound
if p.X < 0 || p.Y < 0 ||
p.X >= common.ScreenWidth/common.GridSize || p.Y >= common.ScreenHeight/common.GridSize {
return true
}
// is newhead collision
for _, snakeBody := range points {
if snakeBody == p {
return true
}
}
return false
}
// RandomPosition
func RandomPosition(randGenerator *rand.Rand) Point {
return Point{
X: randGenerator.Intn(common.ScreenWidth / common.GridSize),
Y: randGenerator.Intn(common.ScreenHeight / common.GridSize),
}
}
package internal
import (
"errors"
"image/color"
"math/rand"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/common"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/entity"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/game"
"github.com/leetcode-golang-classroom/golang-sample-with-snake-game/internal/math"
)
var (
MplusFaceSource *text.GoTextFaceSource
)
type Game struct {
world *game.World
lastUpdate time.Time
gameOver bool
}
func (g *Game) Update() error {
if g.gameOver {
return nil
}
playerRaw, ok := g.world.GetFirstEntity("player")
if !ok {
return errors.New("entity player was not found")
}
player := playerRaw.(*entity.Player)
// handle key
if ebiten.IsKeyPressed(ebiten.KeyW) {
player.SetDirection(math.DirUp)
} else if ebiten.IsKeyPressed(ebiten.KeyS) {
player.SetDirection(math.DirDown)
} else if ebiten.IsKeyPressed(ebiten.KeyA) {
player.SetDirection(math.DirLeft)
} else if ebiten.IsKeyPressed(ebiten.KeyD) {
player.SetDirection(math.DirRight)
}
// slow down
if time.Since(g.lastUpdate) < common.GameSpeed {
return nil
}
g.lastUpdate = time.Now()
for _, entity := range g.world.Entities() {
if entity.Update(g.world) {
g.gameOver = true
return nil
}
}
return nil
}
// drawGameOverText - draw game over text on screen
func (g *Game) drawGameOverText(screen *ebiten.Image) {
face := &text.GoTextFace{
Source: MplusFaceSource,
Size: 48,
}
title := "Game Over!"
w, h := text.Measure(title,
face,
face.Size,
)
op := &text.DrawOptions{}
op.GeoM.Translate(common.ScreenWidth/2-w/2, common.ScreenHeight/2-h/2)
op.ColorScale.ScaleWithColor(color.White)
text.Draw(
screen,
title,
face,
op,
)
}
// Draw - handle screen update
func (g *Game) Draw(screen *ebiten.Image) {
for _, entity := range g.world.Entities() {
entity.Draw(screen)
}
if g.gameOver {
g.drawGameOverText(screen)
}
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return common.ScreenWidth, common.ScreenHeight
}
// NewGame - create Game
func NewGame() *Game {
world := game.NewWorld()
world.AddEntity(
entity.NewPlayer(
math.Point{
X: common.ScreenWidth / common.GridSize / 2,
Y: common.ScreenHeight / common.GridSize / 2,
},
math.DirRight,
),
)
randomGenerator := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 10; i++ {
world.AddEntity(
entity.NewFood(
randomGenerator,
),
)
}
return &Game{
world: world,
}
}