commit a106a26277f83e2cb79e4d6725facdb2b6f7ae33 Author: mimu Date: Sat Aug 22 16:33:18 2026 +0200 v0 diff --git a/colors/colors.go b/colors/colors.go new file mode 100644 index 0000000..6dc3516 --- /dev/null +++ b/colors/colors.go @@ -0,0 +1,10 @@ +package colors + +import "image/color" + +var ( + Black = color.RGBA{0, 0, 0, 1} + LightGray = color.RGBA{24, 24, 24, 1} + Red = color.RGBA{186, 46, 46, 1} + DarkYellow = color.RGBA{184, 178, 94, 1} +) diff --git a/components/buttons/menu_button.go b/components/buttons/menu_button.go new file mode 100644 index 0000000..17a7f69 --- /dev/null +++ b/components/buttons/menu_button.go @@ -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() +} diff --git a/components/game.go b/components/game.go new file mode 100644 index 0000000..4089ac3 --- /dev/null +++ b/components/game.go @@ -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 +} diff --git a/components/menu.go b/components/menu.go new file mode 100644 index 0000000..ddad604 --- /dev/null +++ b/components/menu.go @@ -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() +} diff --git a/components/snake.go b/components/snake.go new file mode 100644 index 0000000..1fb5112 --- /dev/null +++ b/components/snake.go @@ -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{}) +} diff --git a/components/texts/text.go b/components/texts/text.go new file mode 100644 index 0000000..cf27363 --- /dev/null +++ b/components/texts/text.go @@ -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 +} diff --git a/fonts/rockers_garage.ttf b/fonts/rockers_garage.ttf new file mode 100644 index 0000000..b12c7bc Binary files /dev/null and b/fonts/rockers_garage.ttf differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..261fcb4 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/UnintendedFraud/snake-game + +go 1.22.0 + +require github.com/hajimehoshi/ebiten/v2 v2.7.3 + +require ( + github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.7.0 // indirect + github.com/go-text/typesetting v0.1.1-0.20240325125605-c7936fe59984 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/image v0.15.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..000bb01 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8 h1:5e8X7WEdOWrjrKvgaWF6PRnDvJicfrkEnwAkWtMN74g= +github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8/go.mod h1:tWboRRNagZwwwis4QIgEFG1ZNFwBJ3LAhSLAXAAxobQ= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.7.0 h1:HPZpl61edMGCEW6XK2nsR6+7AnJ3unUxpTZBkkIXnMc= +github.com/ebitengine/purego v0.7.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/go-text/typesetting v0.1.1-0.20240325125605-c7936fe59984 h1:NwCC36eQsDf1xVZG9jD7ngXNNjsvk8KXky15ogA1Vo0= +github.com/go-text/typesetting v0.1.1-0.20240325125605-c7936fe59984/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= +github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= +github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/hajimehoshi/bitmapfont/v3 v3.0.0 h1:r2+6gYK38nfztS/et50gHAswb9hXgxXECYgE8Nczmi4= +github.com/hajimehoshi/bitmapfont/v3 v3.0.0/go.mod h1:+CxxG+uMmgU4mI2poq944i3uZ6UYFfAkj9V6WqmuvZA= +github.com/hajimehoshi/ebiten/v2 v2.7.3 h1:lDpj8KbmmjzwD19rsjXNkyelicu0XGvklZW6/tjrgNs= +github.com/hajimehoshi/ebiten/v2 v2.7.3/go.mod h1:1vjyPw+h3n30rfTOpIsbWRXSxZ0Oz1cYc6Tq/2DKoQg= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= +golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..416b836 --- /dev/null +++ b/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/UnintendedFraud/snake-game/components" + "github.com/hajimehoshi/ebiten/v2" +) + +func main() { + game := components.InitGame() + + if err := ebiten.RunGame(game); err != nil { + panic(err) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6250c9f --- /dev/null +++ b/readme.md @@ -0,0 +1,4 @@ +# Snake game + +Trying to create a small snake game using [https://ebitengine.org/](https://ebitengine.org/). + diff --git a/utils/geometry.go b/utils/geometry.go new file mode 100644 index 0000000..6ce3df1 --- /dev/null +++ b/utils/geometry.go @@ -0,0 +1,10 @@ +package utils + +import "image" + +func GetCenter(x, y int) image.Point { + return image.Point{ + X: x / 2, + Y: y / 2, + } +} diff --git a/utils/slice.go b/utils/slice.go new file mode 100644 index 0000000..e5d77f4 --- /dev/null +++ b/utils/slice.go @@ -0,0 +1,36 @@ +package utils + +import "fmt" + +func SliceIndexOf[T any]( + s []T, + fn func(T) bool, +) (int, error) { + for i, el := range s { + if fn(el) { + return i, nil + } + } + + return -1, fmt.Errorf("failed to find a matching element") +} + +func SliceContains[T comparable](s []T, el T) bool { + for _, element := range s { + if element == el { + return true + } + } + + return false +} + +func SliceContainsCustom[T comparable](s []T, fn func(el T) bool) bool { + for _, el := range s { + if fn(el) { + return true + } + } + + return false +} diff --git a/utils/slice_test.go b/utils/slice_test.go new file mode 100644 index 0000000..75959fe --- /dev/null +++ b/utils/slice_test.go @@ -0,0 +1,120 @@ +package utils + +import "testing" + +type complexStruct struct { + field string + value int +} + +func Test_IndexOf(t *testing.T) { + type expected struct { + idx int + errIsNull bool + } + type example[T any] struct { + name string + s []T + searchFn func(T) bool + expected expected + } + + strSlice := []string{"one", "two", "three"} + complexSlice := []complexStruct{ + {field: "test", value: 12}, + {field: "something", value: 333}, + {field: "else", value: 3432}, + {field: "foo", value: 2}, + } + + examples := []example[string]{ + { + name: "valid index in slice of strings", + s: strSlice, + searchFn: func(s string) bool { return s == "three" }, + expected: expected{idx: 2, errIsNull: true}, + }, + { + name: "invalid index in slice of strings", + s: strSlice, + searchFn: func(s string) bool { return s == "invalid" }, + expected: expected{idx: -1, errIsNull: false}, + }, + } + + cplxExamples := []example[complexStruct]{ + { + name: "valid index in slice of custom struct", + s: complexSlice, + searchFn: func(el complexStruct) bool { return el.value == 333 }, + expected: expected{idx: 1, errIsNull: true}, + }, + { + name: "invalid index in slice of custom struct", + s: complexSlice, + searchFn: func(el complexStruct) bool { return el.field == "invalid" }, + expected: expected{idx: -1, errIsNull: false}, + }, + } + + for _, ex := range examples { + idx, err := SliceIndexOf(ex.s, ex.searchFn) + + if idx != ex.expected.idx { + t.Errorf("%s: expected index [%d] but got [%d]", ex.name, ex.expected.idx, idx) + } + + errIsNull := err == nil + if errIsNull != ex.expected.errIsNull { + t.Errorf("%s: expected nil error [%t] but got [%t]", ex.name, ex.expected.errIsNull, errIsNull) + } + } + + for _, ex := range cplxExamples { + idx, err := SliceIndexOf(ex.s, ex.searchFn) + + if idx != ex.expected.idx { + t.Errorf("%s: expected index [%d] but got [%d]", ex.name, ex.expected.idx, idx) + } + + errIsNull := err == nil + if errIsNull != ex.expected.errIsNull { + t.Errorf("%s: expected nil error [%t] but got [%t]", ex.name, ex.expected.errIsNull, errIsNull) + } + } +} + +func Test_Contains(t *testing.T) { + type examples[T comparable] struct { + s []T + values []T + expected []bool + } + + examples1 := examples[int]{ + s: []int{2, 3, 432, 123, 21}, + values: []int{432, 111}, + expected: []bool{true, false}, + } + examples2 := examples[string]{ + s: []string{"one", "two", "three"}, + values: []string{"invalid", "one"}, + expected: []bool{false, true}, + } + + for i, v := range examples1.values { + expected := examples1.expected[i] + + if SliceContains(examples1.s, v) != expected { + t.Errorf("Test_Contains - [%v] being in %+v should be %t", v, examples1.s, expected) + } + } + + for i, v := range examples2.values { + expected := examples2.expected[i] + + if SliceContains(examples2.s, v) != expected { + t.Errorf("Test_Contains - [%v] being in %+v should be %t", v, examples2.s, expected) + } + } +}