This commit is contained in:
2026-08-22 16:33:18 +02:00
commit a106a26277
14 changed files with 878 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
package buttons
import (
"image/color"
"github.com/UnintendedFraud/snake-game/colors"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
)
type MenuAction int
const (
StartGame MenuAction = iota
ExitGame
ReturnToMenu
)
type MenuButton struct {
Value string
Action MenuAction
Img *ebiten.Image
}
func (b *MenuButton) Execute() {
}
func (b *MenuButton) GetMenuButtonImg(
font *text.GoTextFaceSource,
fontSize float64,
isFocused bool,
) *ebiten.Image {
img := ebiten.NewImage(200, 50)
fontOptions := &text.DrawOptions{}
if isFocused {
fontOptions.ColorScale.ScaleWithColor(colors.Red)
} else {
fontOptions.ColorScale.ScaleWithColor(color.White)
}
text.Draw(
img,
b.Value,
&text.GoTextFace{
Source: font,
Size: fontSize,
},
fontOptions,
)
return img
}
func (b *MenuButton) Clear() {
b.Img.Clear()
}
+116
View File
@@ -0,0 +1,116 @@
package components
import (
"image/color"
"os"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
)
const (
WINDOW_WIDTH int = 1200
WINDOW_HEIGHT int = 800
)
type GameState int
const (
MainMenu GameState = iota
Playing
Dead
)
type Game struct {
font *text.GoTextFaceSource
titleImg *ebiten.Image
state GameState
snake *Snake
menu *Menu
}
func InitGame() *Game {
ebiten.SetWindowSize(WINDOW_WIDTH, WINDOW_HEIGHT)
ebiten.SetWindowTitle("Snake")
fontFile, err := os.Open("fonts/rockers_garage.ttf")
if err != nil {
panic(err)
}
font, err := text.NewGoTextFaceSource(fontFile)
if err != nil {
panic(err)
}
return &Game{
font: font,
titleImg: initTitle(font),
state: MainMenu,
snake: InitSnake(WINDOW_WIDTH, WINDOW_HEIGHT),
menu: InitMenu(),
}
}
func (g *Game) Update() error {
switch g.state {
case MainMenu:
g.menu.UpdateFocus()
g.menu.Click(g)
case Playing:
g.snake.Eat()
g.snake.Sprint()
g.snake.ManageDirection()
g.snake.Move()
if g.snake.HasCollided() {
g.snake.isDead = true
}
if g.snake.isDead {
g.snake.Click(g)
}
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
screen.DrawImage(g.titleImg, &ebiten.DrawImageOptions{})
switch g.state {
case MainMenu:
g.menu.Render(screen, g.font)
case Playing:
g.snake.Render(screen, g.font)
}
}
func (g *Game) Layout(outsideWith, outsideHeight int) (screenWidth, screenHeight int) {
return WINDOW_WIDTH, WINDOW_HEIGHT
}
func initTitle(font *text.GoTextFaceSource) *ebiten.Image {
face := &text.GoTextFace{
Source: font,
Size: 60,
}
titleW, _ := text.Measure("snake", face, 0)
fontOptions := &text.DrawOptions{}
fontOptions.GeoM.Translate(float64(WINDOW_WIDTH)/2-titleW/2, 10)
fontOptions.ColorScale.ScaleWithColor(color.RGBA{194, 169, 60, 1})
h := float64(WINDOW_HEIGHT) * 0.1
titleImg := ebiten.NewImage(WINDOW_WIDTH, int(h))
text.Draw(titleImg, "snake", face, fontOptions)
return titleImg
}
+89
View File
@@ -0,0 +1,89 @@
package components
import (
"fmt"
"os"
"github.com/UnintendedFraud/snake-game/components/buttons"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
)
type Menu struct {
focusedIdx int
buttons []buttons.MenuButton
img *ebiten.Image
}
func InitMenu() *Menu {
return &Menu{
img: ebiten.NewImage(200, 200),
focusedIdx: 0,
buttons: []buttons.MenuButton{
{
Value: "start game",
Action: buttons.StartGame,
},
{
Value: "exit game",
Action: buttons.ExitGame,
},
},
}
}
func (m *Menu) Click(game *Game) {
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) ||
inpututil.IsKeyJustPressed(ebiten.KeyKPEnter) {
m.executeAction(game, m.buttons[m.focusedIdx].Action)
}
}
func (m *Menu) executeAction(game *Game, action buttons.MenuAction) {
switch action {
case buttons.StartGame:
game.snake = InitSnake(WINDOW_WIDTH, WINDOW_HEIGHT)
game.state = Playing
case buttons.ExitGame:
fmt.Println("Closing the game")
os.Exit(0)
}
}
func (m *Menu) UpdateFocus() {
if inpututil.IsKeyJustPressed(ebiten.KeyUp) {
if m.focusedIdx != 0 {
m.focusedIdx--
}
}
if inpututil.IsKeyJustPressed(ebiten.KeyDown) {
if m.focusedIdx != len(m.buttons)-1 {
m.focusedIdx++
}
}
}
func (m *Menu) Render(screen *ebiten.Image, font *text.GoTextFaceSource) {
m.Clear()
for idx, b := range m.buttons {
menuImg := b.GetMenuButtonImg(font, 24, m.focusedIdx == idx)
x := float64(0)
y := float64(idx*50 + 50)
menuImgOp := &ebiten.DrawImageOptions{}
menuImgOp.GeoM.Translate(x, y)
m.img.DrawImage(menuImg, menuImgOp)
}
imgOp := &ebiten.DrawImageOptions{}
imgOp.GeoM.Translate(150, 200)
screen.DrawImage(m.img, imgOp)
}
func (m *Menu) Clear() {
m.img.Clear()
}
+338
View File
@@ -0,0 +1,338 @@
package components
import (
"fmt"
"image"
"image/color"
"math/rand"
"slices"
"time"
"github.com/UnintendedFraud/snake-game/colors"
"github.com/UnintendedFraud/snake-game/components/buttons"
"github.com/UnintendedFraud/snake-game/utils"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
type Direction int
const (
Up Direction = iota
Right
Down
Left
)
type Snake struct {
img *ebiten.Image
speed int64
direction Direction
positions []image.Point
sprint bool
ennemies []image.Point
prevTime time.Time
currTime time.Time
diff time.Duration
isDead bool
total int
score int
}
const (
START_LENGTH = 20
THICKNESS = 8
MIN_SPEED int64 = 500
MAX_SPEED int64 = 50
MAX_SPRINT int64 = 20
)
func InitSnake(width, height int) *Snake {
h := int(float64(height) * 0.9)
img := ebiten.NewImage(width, h)
return &Snake{
img: img,
speed: MIN_SPEED,
direction: Right,
positions: getInitPositions(utils.GetCenter(width, height)),
ennemies: randomEnnemies(width, h, 10),
isDead: false,
}
}
func (snake *Snake) Render(screen *ebiten.Image, font *text.GoTextFaceSource) {
if snake == nil || snake.img == nil {
return
}
snake.img.Clear()
renderScoreScreen(screen, snake, font)
if snake.isDead {
renderDeadScreen(screen, snake, font)
return
}
snake.img.Fill(colors.LightGray)
for _, ennemy := range snake.ennemies {
vector.DrawFilledRect(
snake.img,
float32(ennemy.X),
float32(ennemy.Y),
THICKNESS,
THICKNESS,
colors.DarkYellow,
true,
)
}
for _, point := range snake.positions {
vector.DrawFilledRect(
snake.img,
float32(point.X),
float32(point.Y),
THICKNESS,
THICKNESS,
color.White,
true,
)
}
options := &ebiten.DrawImageOptions{}
ty := float64(screen.Bounds().Dy()) * 0.1
options.GeoM.Translate(0, ty)
screen.DrawImage(snake.img, options)
}
func (snake *Snake) Sprint() {
if utils.SliceContains(inpututil.AppendPressedKeys([]ebiten.Key{}), ebiten.KeySpace) && !snake.sprint {
snake.sprint = true
} else if snake.sprint {
snake.sprint = false
}
}
func (snake *Snake) ManageDirection() {
if inpututil.IsKeyJustPressed(ebiten.KeyUp) && snake.direction != Down {
snake.direction = Up
}
if inpututil.IsKeyJustPressed(ebiten.KeyRight) && snake.direction != Left {
snake.direction = Right
}
if inpututil.IsKeyJustPressed(ebiten.KeyDown) && snake.direction != Up {
snake.direction = Down
}
if inpututil.IsKeyJustPressed(ebiten.KeyLeft) && snake.direction != Right {
snake.direction = Left
}
}
func (snake *Snake) HasCollided() bool {
head := snake.positions[0]
maxX := snake.img.Bounds().Dx()
maxY := snake.img.Bounds().Dy()
if head.X <= 0 || head.X >= maxX || head.Y <= 0 || head.Y >= maxY {
return true
}
return snake.hitItself()
}
func (snake *Snake) Eat() {
head := snake.positions[0]
idx, err := utils.SliceIndexOf(snake.ennemies, func(e image.Point) bool {
return e.X == head.X && e.Y == head.Y
})
if err != nil || idx < 0 {
return
}
snake.ennemies = slices.Delete(snake.ennemies, idx, idx+1)
snake.positions = append(
[]image.Point{snake.getNextPosition()},
snake.positions...,
)
if snake.speed > MAX_SPEED {
snake.speed -= 50
}
}
func (snake *Snake) Move() {
snake.prevTime = snake.currTime
snake.currTime = time.Now()
snake.diff += snake.currTime.Sub(snake.prevTime)
var speed int64
if snake.sprint {
speed = MAX_SPRINT
} else {
speed = snake.speed
}
if snake.diff.Milliseconds() < speed {
return
}
snake.diff = 0
snake.positions = append(
[]image.Point{snake.getNextPosition()},
snake.positions[0:len(snake.positions)-1]...,
)
}
func (snake *Snake) hitItself() bool {
head := snake.positions[0]
for i := 1; i < len(snake.positions); i++ {
p := snake.positions[i]
if head.X == p.X && head.Y == p.Y {
return true
}
}
return false
}
func (snake *Snake) getNextPosition() image.Point {
nextHead := snake.positions[0]
switch snake.direction {
case Up:
nextHead.Y = nextHead.Y - THICKNESS
case Right:
nextHead.X = nextHead.X + THICKNESS
case Down:
nextHead.Y = nextHead.Y + THICKNESS
case Left:
nextHead.X = nextHead.X - THICKNESS
}
return nextHead
}
func (snake *Snake) Click(game *Game) {
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) ||
inpututil.IsKeyJustPressed(ebiten.KeyKPEnter) {
game.state = MainMenu
}
}
func getInitPositions(center image.Point) []image.Point {
points := []image.Point{}
for i := range START_LENGTH {
points = append(points, image.Point{X: center.X - i*THICKNESS, Y: center.Y})
}
return points
}
func randomEnnemies(width int, height int, count int) []image.Point {
ennemies := []image.Point{}
w := width / THICKNESS
h := height / THICKNESS
for range count {
x := rand.Intn(w) * THICKNESS
y := rand.Intn(h) * THICKNESS
ennemies = append(ennemies, image.Point{X: x, Y: y})
}
return ennemies
}
func renderScoreScreen(screen *ebiten.Image, snake *Snake, font *text.GoTextFaceSource) {
scoreImg := ebiten.NewImage(WINDOW_WIDTH, 100)
scoreImg.Fill(colors.Red)
levelOptions := &text.DrawOptions{}
levelOptions.ColorScale.ScaleWithColor(color.White)
levelOptions.GeoM.Translate(10, 10)
text.Draw(
scoreImg,
"Level 1",
&text.GoTextFace{
Source: font,
Size: 25,
},
levelOptions,
)
scoreOptions := &text.DrawOptions{}
scoreOptions.ColorScale.ScaleWithColor(color.White)
scoreOptions.GeoM.Translate(100, 10)
text.Draw(
scoreImg,
fmt.Sprintf("Score: %d/%d", snake.score, snake.total),
&text.GoTextFace{
Source: font,
Size: 25,
},
scoreOptions,
)
snake.img.DrawImage(scoreImg, &ebiten.DrawImageOptions{})
screen.DrawImage(snake.img, &ebiten.DrawImageOptions{})
}
func renderDeadScreen(screen *ebiten.Image, snake *Snake, font *text.GoTextFaceSource) {
snake.img.Fill(colors.Black)
fontOptions := &text.DrawOptions{}
fontOptions.ColorScale.ScaleWithColor(colors.Red)
deadImg := ebiten.NewImage(450, 90)
text.Draw(
deadImg,
"You are DEAD !",
&text.GoTextFace{
Source: font,
Size: 80,
},
fontOptions,
)
deadImgW := deadImg.Bounds().Dx()
deadImgH := deadImg.Bounds().Dy()
x := float64(screen.Bounds().Dx()/2 - deadImgW/2)
y := float64(screen.Bounds().Dy()/2 - deadImgH/2)
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(x, y)
snake.img.DrawImage(deadImg, op)
// ---- return to menu button
returnToMenuButton := buttons.MenuButton{
Value: "return to menu",
Action: buttons.ReturnToMenu,
}
rtmbImg := returnToMenuButton.GetMenuButtonImg(font, 24, true)
rtmOp := &ebiten.DrawImageOptions{}
rtmOp.GeoM.Translate(x, y+100)
snake.img.DrawImage(rtmbImg, rtmOp)
screen.DrawImage(snake.img, &ebiten.DrawImageOptions{})
}
+43
View File
@@ -0,0 +1,43 @@
package texts
import (
"image"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
)
type Text struct {
Value string
Size float64
Font *text.GoTextFaceSource
Color color.Color
}
func (t *Text) Render(containerImg *ebiten.Image, pos image.Point) {
textImg := t.GetTextImg()
menuImgOp := &ebiten.DrawImageOptions{}
menuImgOp.GeoM.Translate(float64(pos.X), float64(pos.Y))
containerImg.DrawImage(textImg, menuImgOp)
}
func (t *Text) GetTextImg() *ebiten.Image {
img := ebiten.NewImage(200, 50)
fontOptions := &text.DrawOptions{}
fontOptions.ColorScale.ScaleWithColor(t.Color)
text.Draw(
img,
t.Value,
&text.GoTextFace{
Source: t.Font,
Size: t.Size,
},
fontOptions,
)
return img
}