From ea5b5c4e75b9165f701f65e877875730b5aa70be Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Tue, 16 Dec 2025 11:33:00 +0000 Subject: [PATCH] Initial commit implemented the game of life variation with hexagonal grid --- README.md | 20 +++ go.mod | 25 ++++ go.sum | 40 +++++ grid/hex.go | 183 +++++++++++++++++++++++ grid/hex_test.go | 83 +++++++++++ main.go | 169 +++++++++++++++++++++ math/comp.go | 37 +++++ math/comp_test.go | 75 ++++++++++ math/const.go | 5 + math/ops.go | 39 +++++ math/ops_test.go | 47 ++++++ math/pair.go | 19 +++ ticker.go | 30 ++++ ui/colors.go | 25 ++++ ui/event_bus.go | 58 ++++++++ ui/events.go | 9 ++ ui/root.go | 365 ++++++++++++++++++++++++++++++++++++++++++++++ 17 files changed, 1229 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 grid/hex.go create mode 100644 grid/hex_test.go create mode 100644 main.go create mode 100644 math/comp.go create mode 100644 math/comp_test.go create mode 100644 math/const.go create mode 100644 math/ops.go create mode 100644 math/ops_test.go create mode 100644 math/pair.go create mode 100644 ticker.go create mode 100644 ui/colors.go create mode 100644 ui/event_bus.go create mode 100644 ui/events.go create mode 100644 ui/root.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a5ba40 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Game of life with hexagons + +Just a small experiment with Ebitengine and hexagonal grids. + +The grid implementation was heavily inspired by Amit Patel blog post: [Hexagonal Grids](https://www.redblobgames.com/grids/hexagons/). + +# Running + +Pull the repo and then + +```sh +go run . +``` + +or build and run + +```sh +go build . +./game-of-life +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..97ed0e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/nmoniz/game-of-life + +go 1.25.3 + +require github.com/hajimehoshi/ebiten/v2 v2.9.4 + +require ( + github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8 // indirect + github.com/go-text/typesetting v0.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/image v0.31.0 // indirect + golang.org/x/text v0.29.0 // indirect +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/ebitenui/ebitenui v0.7.2 + github.com/jezek/xgb v1.1.1 // indirect + github.com/nmoniz/gubgub v0.0.0-20251120170732-570e6317c0fa + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..32a5a52 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +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.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitenui/ebitenui v0.7.2 h1:gSMiKvgJbrbYo57hrYeI3vRzE12kIFDNq4X09WLgM/o= +github.com/ebitenui/ebitenui v0.7.2/go.mod h1:QiJoDflkWoBv4V/LKErS3cgzTZHrXDQyqajef7IA8vM= +github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8 h1:sdIsYe6Vv7KIWZWp8KqSeTl+XlF17d+wHCC4lbxFcYs= +github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8/go.mod h1:0QBxkXxN+o4FyZgLI9FHY/oUizheze3+bNY/kgCKL+4= +github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= +github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= +github.com/hajimehoshi/ebiten/v2 v2.9.4 h1:IlPJpwtksylmmvNhQjv4W2bmCFWXtjY7Z10Esise1bk= +github.com/hajimehoshi/ebiten/v2 v2.9.4/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/nmoniz/gubgub v0.0.0-20251120170732-570e6317c0fa h1:oXNcFTkvGRVHk2wYrVauFnihBQFLxFHrw7HeUBjq3CU= +github.com/nmoniz/gubgub v0.0.0-20251120170732-570e6317c0fa/go.mod h1:bCxDGZD//WQAsr6yDyytOhd5tfx79MJJ+/nENzHO6yM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grid/hex.go b/grid/hex.go new file mode 100644 index 0000000..44c6cb7 --- /dev/null +++ b/grid/hex.go @@ -0,0 +1,183 @@ +package grid + +import ( + "fmt" + + "github.com/nmoniz/game-of-life/math" +) + +type HexLayout struct { + Orientation Orientation + Origin math.Pair[int] + Radius math.Pair[int] +} + +func (l HexLayout) HexToPix(h Hex) math.Pair[int] { + m := l.Orientation.forward + + x := float64(l.Radius.X) * (m[0]*float64(h.q) + m[1]*float64(h.r)) + y := float64(l.Radius.Y) * (m[2]*float64(h.q) + m[3]*float64(h.r)) + + return math.Pair[int]{ + X: int(x) + l.Origin.X, + Y: int(y) + l.Origin.Y, + } +} + +func (l HexLayout) PixToHex(c math.Pair[int]) Hex { + relC := math.Pair[float64]{ + X: float64(c.X-l.Origin.X) / float64(l.Radius.X), + Y: float64(c.Y-l.Origin.Y) / float64(l.Radius.Y), + } + + m := l.Orientation.inverse + + return NewHexRounded( + m[0]*relC.X+m[1]*relC.Y, + m[2]*relC.X+m[3]*relC.Y, + ) +} + +type Hex struct { + q, r, s int +} + +func NewHex(q, r int) Hex { + return Hex{ + q: q, + r: r, + s: -q - r, + } +} + +func NewHexRounded(q, r float64) Hex { + var ( + s = -q - r + qi = math.RoundInt(q) + ri = math.RoundInt(r) + si = math.RoundInt(s) + ) + + if qi+ri+si == 0 { + return Hex{ + q: qi, + r: ri, + s: si, + } + } + + var ( + qdiff = math.Abs(float64(qi) - q) + rdiff = math.Abs(float64(ri) - r) + sdiff = math.Abs(float64(si) - s) + ) + + switch { + case qdiff > rdiff && qdiff > sdiff: + qi = -ri - si + case rdiff > sdiff: + ri = -qi - si + default: + si = -qi - ri + } + + return Hex{ + q: qi, + r: ri, + s: si, + } +} + +func (h Hex) Q() int { + return h.q +} + +func (h Hex) R() int { + return h.r +} + +func (h Hex) S() int { + return h.s +} + +func (h Hex) Add(o Hex) Hex { + return Hex{ + q: h.q + o.q, + r: h.r + o.r, + s: h.s + o.s, + } +} + +func (h Hex) Sub(o Hex) Hex { + return Hex{ + q: h.q - o.q, + r: h.r - o.r, + s: h.s - o.s, + } +} + +func (h Hex) Mul(o Hex) Hex { + return Hex{ + q: h.q * o.q, + r: h.r * o.r, + s: h.s * o.s, + } +} + +func (h Hex) Equal(other Hex) bool { + return h.q == other.q && h.r == other.r && h.s == other.s +} + +func (h Hex) Length() int { + return (math.Abs(h.q) + math.Abs(h.r) + math.Abs(h.s)) / 2 +} + +func (h Hex) Distance(o Hex) int { + return h.Sub(o).Length() +} + +func (h Hex) AllNeighbors() []Hex { + return []Hex{ + NewHex(h.q+1, h.r), + NewHex(h.q, h.r+1), + NewHex(h.q-1, h.r+1), + NewHex(h.q-1, h.r), + NewHex(h.q, h.r-1), + NewHex(h.q+1, h.r-1), + } +} + +func (h Hex) NeighborAt(direction int) Hex { + neighbors := h.AllNeighbors() + + direction = direction % 6 + + if direction < 0 { + direction += 6 + } + + return neighbors[direction] +} + +func (h Hex) String() string { + return fmt.Sprintf("(%d,%d,%d)", h.Q(), h.R(), h.S()) +} + +type Orientation struct { + forward []float64 + inverse []float64 +} + +func PointyTopLayout() Orientation { + return Orientation{ + forward: []float64{math.Sqrt(3.0), math.Sqrt(3.0) / 2.0, 0.0, 3.0 / 2.0}, + inverse: []float64{math.Sqrt(3.0) / 3.0, -1.0 / 3.0, 0.0, 2.0 / 3.0}, + } +} + +func FlatTopLayout() Orientation { + return Orientation{ + forward: []float64{3.0 / 2.0, 0.0, math.Sqrt(3.0) / 2.0, math.Sqrt(3.0)}, + inverse: []float64{2.0 / 3.0, 0.0, -1.0 / 3.0, math.Sqrt(3.0) / 3.0}, + } +} diff --git a/grid/hex_test.go b/grid/hex_test.go new file mode 100644 index 0000000..3f8c6c2 --- /dev/null +++ b/grid/hex_test.go @@ -0,0 +1,83 @@ +package grid + +import ( + "testing" +) + +func TestHex_NeighborAt(t *testing.T) { + testCases := []struct { + name string + hex Hex + direction int + want Hex + }{ + { + name: "zero hex zero direction", + hex: Hex{}, + direction: 0, + want: NewHex(1, 0), + }, + { + name: "zero hex direction 1", + hex: Hex{}, + direction: 1, + want: NewHex(0, 1), + }, + { + name: "zero hex direction 2", + hex: Hex{}, + direction: 2, + want: NewHex(-1, 1), + }, + { + name: "zero hex direction 3", + hex: Hex{}, + direction: 3, + want: NewHex(-1, 0), + }, + { + name: "zero hex direction 4", + hex: Hex{}, + direction: 4, + want: NewHex(0, -1), + }, + { + name: "zero hex zero direction 5", + hex: Hex{}, + direction: 5, + want: NewHex(1, -1), + }, + { + name: "zero hex zero direction overflow", + hex: Hex{}, + direction: 6, + want: NewHex(1, 0), + }, + { + name: "zero hex zero direction overflow twice", + hex: Hex{}, + direction: 12, + want: NewHex(1, 0), + }, + { + name: "zero hex zero direction underflow", + hex: Hex{}, + direction: -1, + want: NewHex(1, -1), + }, + { + name: "zero hex zero direction underflow twice", + hex: Hex{}, + direction: -7, + want: NewHex(1, -1), + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := tc.hex.NeighborAt(tc.direction) + if tc.want != got { + t.Fatalf("want %v but got %v", tc.want, got) + } + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..870c49c --- /dev/null +++ b/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "image/color" + "log" + "time" + + "github.com/nmoniz/game-of-life/grid" + "github.com/nmoniz/game-of-life/math" + "github.com/nmoniz/game-of-life/ui" + + "github.com/ebitenui/ebitenui" + "github.com/ebitenui/ebitenui/input" + "github.com/ebitenui/ebitenui/widget" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +type Game struct { + ui *ebitenui.UI + + Grid grid.HexLayout + Alive map[grid.Hex]struct{} + Dead map[grid.Hex]struct{} + Ticker Ticker + Speed int + Paused bool + + UnderPopThreshold int + OverPopThreshold int + ReproductionTarget int +} + +func NewGame() *Game { + g := &Game{ + ui: &ebitenui.UI{Container: ui.Root()}, + + Grid: grid.HexLayout{ + Orientation: grid.PointyTopLayout(), + Origin: math.Pair[int]{X: 4, Y: 4}, + Radius: math.Pair[int]{X: 8, Y: 8}, + }, + Alive: make(map[grid.Hex]struct{}), + Dead: make(map[grid.Hex]struct{}), + + Ticker: *NewTicker(1, time.Second), + Speed: 0, + + OverPopThreshold: 5, + UnderPopThreshold: 2, + ReproductionTarget: 2, + } + + _ = ui.EventBus().Listen(ui.SpeedSliderEvent, func(e ui.Event) { + args, ok := e.Data().(*widget.SliderChangedEventArgs) + if !ok { + return + } + + g.Speed = args.Current + + if g.Speed > 0 { + g.Ticker.Reset(g.Speed, time.Second) + } + }) + + _ = ui.EventBus().Listen(ui.OverPopulationSliderEvent, func(e ui.Event) { + args, ok := e.Data().(*widget.SliderChangedEventArgs) + if !ok { + return + } + + g.OverPopThreshold = args.Current + }) + + _ = ui.EventBus().Listen(ui.UnderPopulationSliderEvent, func(e ui.Event) { + args, ok := e.Data().(*widget.SliderChangedEventArgs) + if !ok { + return + } + + g.UnderPopThreshold = args.Current + }) + + _ = ui.EventBus().Listen(ui.ReproductionTargetEvent, func(e ui.Event) { + args, ok := e.Data().(*widget.SliderChangedEventArgs) + if !ok { + return + } + + g.ReproductionTarget = args.Current + }) + + _ = ui.EventBus().Listen(ui.ResetButtonEvent, func(ui.Event) { + g.Alive = make(map[grid.Hex]struct{}) + g.Dead = make(map[grid.Hex]struct{}) + }) + + return g +} + +func (g *Game) Update() error { + g.ui.Update() + + if !input.UIHovered && input.MouseButtonJustPressed(ebiten.MouseButtonLeft) { + x, y := ebiten.CursorPosition() + hexPos := g.Grid.PixToHex(math.NewPair(x, y)) + if _, ok := g.Alive[hexPos]; ok { + delete(g.Alive, hexPos) + } else { + g.Alive[hexPos] = struct{}{} + } + } + + if g.Speed == 0 { + return nil + } + + if len(g.Alive) > 0 && g.Ticker.HasTicked() { + g.Dead = make(map[grid.Hex]struct{}) + + neighbourhood := make(map[grid.Hex]int) + for hex := range g.Alive { + neighbourhood[hex] = 0 + for _, neighbourHex := range hex.AllNeighbors() { + neighbourhood[neighbourHex]++ + } + } + + for hex, count := range neighbourhood { + if _, ok := g.Alive[hex]; ok { + if count < g.UnderPopThreshold || count > g.OverPopThreshold { + g.Dead[hex] = struct{}{} + delete(g.Alive, hex) + } + } else if count == g.ReproductionTarget { + g.Alive[hex] = struct{}{} + } + } + } + + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + for k := range g.Dead { + pixPosition := g.Grid.HexToPix(k) + vector.FillCircle(screen, float32(pixPosition.X), float32(pixPosition.Y), 5, color.RGBA{128, 128, 128, 0}, true) + } + + for k := range g.Alive { + pixPosition := g.Grid.HexToPix(k) + vector.FillCircle(screen, float32(pixPosition.X), float32(pixPosition.Y), 5, color.RGBA{0, 255, 0, 0}, true) + } + + g.ui.Draw(screen) +} + +func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { + return 800, 600 +} + +func main() { + ebiten.SetWindowSize(800, 600) + ebiten.SetWindowTitle("Game of Life with hexagons") + if err := ebiten.RunGame(NewGame()); err != nil { + log.Fatal(err) + } +} diff --git a/math/comp.go b/math/comp.go new file mode 100644 index 0000000..d99d7fa --- /dev/null +++ b/math/comp.go @@ -0,0 +1,37 @@ +package math + +func Max[T SignedNumber](nLst ...T) T { + switch len(nLst) { + case 0: + var zeroT T + return zeroT + case 1: + return nLst[0] + default: + m := nLst[0] + for _, n := range nLst[1:] { + if m < n { + m = n + } + } + return m + } +} + +func Min[T SignedNumber](nLst ...T) T { + switch len(nLst) { + case 0: + var zeroT T + return zeroT + case 1: + return nLst[0] + default: + m := nLst[0] + for _, n := range nLst[1:] { + if m > n { + m = n + } + } + return m + } +} diff --git a/math/comp_test.go b/math/comp_test.go new file mode 100644 index 0000000..4889bdd --- /dev/null +++ b/math/comp_test.go @@ -0,0 +1,75 @@ +package math + +import ( + "testing" +) + +func TestMax(t *testing.T) { + testCases := []struct { + name string + values []int + exp int + }{ + { + name: "multiple unique", + values: []int{3, 2, 4, 1}, + exp: 4, + }, + { + name: "single", + values: []int{5}, + exp: 5, + }, + { + name: "empty", + }, + { + name: "multiple duplicates", + values: []int{2, 2, 2, 1}, + exp: 2, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + act := Max(tc.values...) + if tc.exp != act { + t.Fatalf("want %v but got %v", tc.exp, act) + } + }) + } +} + +func TestMin(t *testing.T) { + testCases := []struct { + name string + values []int + exp int + }{ + { + name: "multiple unique", + values: []int{3, 2, 4, 1}, + exp: 1, + }, + { + name: "single", + values: []int{5}, + exp: 5, + }, + { + name: "empty", + }, + { + name: "multiple duplicates", + values: []int{2, 1, 1, 1}, + exp: 1, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + act := Min(tc.values...) + if tc.exp != act { + t.Fatalf("want %v but got %v", tc.exp, act) + } + }) + } +} diff --git a/math/const.go b/math/const.go new file mode 100644 index 0000000..3e06275 --- /dev/null +++ b/math/const.go @@ -0,0 +1,5 @@ +package math + +import "math" + +const Pi = math.Pi diff --git a/math/ops.go b/math/ops.go new file mode 100644 index 0000000..aaa2c19 --- /dev/null +++ b/math/ops.go @@ -0,0 +1,39 @@ +package math + +import ( + "math" +) + +type SignedNumber interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 +} + +type Float interface { + ~float32 | ~float64 +} + +func Abs[T SignedNumber](i T) T { + var zero T + if i < zero { + return -i + } + return i +} + +func Round[T Float](n T, precision int) T { + if precision == 0 { + return T(math.Round(float64(n))) + } + + ratio := math.Pow(10, float64(precision)) + f := math.Round(float64(n)*ratio) / ratio + return T(f) +} + +func RoundInt[T Float](n T) int { + return int(Round(n, 0)) +} + +func Sqrt[T Float](n T) T { + return T(math.Sqrt(float64(n))) +} diff --git a/math/ops_test.go b/math/ops_test.go new file mode 100644 index 0000000..fc1b1a3 --- /dev/null +++ b/math/ops_test.go @@ -0,0 +1,47 @@ +package math + +import ( + "testing" +) + +func TestAbs(t *testing.T) { + assertEqual(t, 0, Abs(0)) + assertEqual(t, 1, Abs(1)) + assertEqual(t, 2, Abs(-2)) + + assertEqual(t, 0.0, Abs(0.0)) + assertEqual(t, 1.5, Abs(1.5)) + assertEqual(t, 2.75, Abs(-2.75)) +} + +func TestRoundPositive(t *testing.T) { + posf64 := float64(0.987654321) + posf64 = Round(posf64, 2) + assertEqual(t, float64(0.99), posf64) + + posf32 := float32(0.987654321) + posf32 = Round(posf32, 5) + assertEqual(t, float32(0.98765), posf32) + + negf64 := float64(-0.987654321) + negf64 = Round(negf64, 2) + assertEqual(t, float64(-0.99), negf64) + + negf32 := float32(-0.987654321) + negf32 = Round(negf32, 5) + assertEqual(t, float32(-0.98765), negf32) +} + +func TestRoundInt(t *testing.T) { + i := RoundInt(float64(0.5)) + assertEqual(t, 1, i) + + i = RoundInt(float32(1.4999999)) + assertEqual(t, 1, i) +} + +func assertEqual[T comparable](t testing.TB, want, got T) { + if want != got { + t.Fatalf("want %v but got %v", want, got) + } +} diff --git a/math/pair.go b/math/pair.go new file mode 100644 index 0000000..6e7b3f5 --- /dev/null +++ b/math/pair.go @@ -0,0 +1,19 @@ +package math + +import "fmt" + +type Pair[T SignedNumber] struct { + X, Y T +} + +func NewPair[T SignedNumber](x, y T) Pair[T] { + return Pair[T]{X: x, Y: y} +} + +func (p Pair[T]) Equal(other Pair[T]) bool { + return p.X == other.X && p.Y == other.Y +} + +func (c Pair[T]) String() string { + return fmt.Sprintf("(%v,%v)", c.X, c.Y) +} diff --git a/ticker.go b/ticker.go new file mode 100644 index 0000000..3c779e9 --- /dev/null +++ b/ticker.go @@ -0,0 +1,30 @@ +package main + +import ( + "time" +) + +type Ticker struct { + *time.Ticker +} + +func NewTicker(rate int, interval time.Duration) *Ticker { + t := &Ticker{ + Ticker: time.NewTicker(interval / time.Duration(rate)), + } + + return t +} + +func (t *Ticker) Reset(rate int, interval time.Duration) { + t.Ticker.Reset(interval / time.Duration(rate)) +} + +func (t *Ticker) HasTicked() bool { + select { + case <-t.C: + return true + default: + return false + } +} diff --git a/ui/colors.go b/ui/colors.go new file mode 100644 index 0000000..108f766 --- /dev/null +++ b/ui/colors.go @@ -0,0 +1,25 @@ +package ui + +import ( + "image/color" + "math" +) + +func mixColors(a, b color.Color, percent float64) color.Color { + rgba := func(c color.Color) (r, g, b, a uint8) { + r16, g16, b16, a16 := c.RGBA() + return uint8(r16 >> 8), uint8(g16 >> 8), uint8(b16 >> 8), uint8(a16 >> 8) + } + lerp := func(x, y uint8) uint8 { + return uint8(math.Round(float64(x) + percent*(float64(y)-float64(x)))) + } + r1, g1, b1, a1 := rgba(a) + r2, g2, b2, a2 := rgba(b) + + return color.RGBA{ + R: lerp(r1, r2), + G: lerp(g1, g2), + B: lerp(b1, b2), + A: lerp(a1, a2), + } +} diff --git a/ui/event_bus.go b/ui/event_bus.go new file mode 100644 index 0000000..888e80f --- /dev/null +++ b/ui/event_bus.go @@ -0,0 +1,58 @@ +package ui + +import ( + "fmt" + "sync" + + "github.com/nmoniz/gubgub" +) + +var eventBusGetter = sync.OnceValue(func() *eventBus { + return NewSyncEventBus() +}) + +func EventBus() *eventBus { + return eventBusGetter() +} + +type Event interface { + Name() string + Data() any +} + +type eventBus struct { + topics map[string]gubgub.Topic[Event] +} + +func NewSyncEventBus() *eventBus { + return &eventBus{ + topics: make(map[string]gubgub.Topic[Event]), + } +} + +func (eb *eventBus) Emit(e Event) error { + topic, ok := eb.topics[e.Name()] + if !ok { + return nil + } + err := topic.Publish(e) + if err != nil { + return fmt.Errorf("emit event: %w", err) + } + return nil +} + +func (eb *eventBus) Listen(name string, handler func(Event)) error { + topic, ok := eb.topics[name] + if !ok { + topic = gubgub.NewSyncTopic[Event]() + eb.topics[name] = topic + } + + err := topic.Subscribe(gubgub.Forever(handler)) + if err != nil { + return fmt.Errorf("listen for event: %w", err) + } + + return nil +} diff --git a/ui/events.go b/ui/events.go new file mode 100644 index 0000000..1bfdad9 --- /dev/null +++ b/ui/events.go @@ -0,0 +1,9 @@ +package ui + +const ( + ResetButtonEvent = "reset_button" + SpeedSliderEvent = "speed_slider" + ReproductionTargetEvent = "reproduction_target_slider" + OverPopulationSliderEvent = "over_population_slider" + UnderPopulationSliderEvent = "under_population_slider" +) diff --git a/ui/root.go b/ui/root.go new file mode 100644 index 0000000..eae6d2d --- /dev/null +++ b/ui/root.go @@ -0,0 +1,365 @@ +package ui + +import ( + "bytes" + "fmt" + "image/color" + "log" + + "github.com/ebitenui/ebitenui/image" + "github.com/ebitenui/ebitenui/widget" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "golang.org/x/image/colornames" + "golang.org/x/image/font/gofont/goregular" +) + +func Root() *widget.Container { + face, err := loadFont(12) + if err != nil { + panic(err) + } + + slidersTrack := &widget.SliderTrackImage{ + Idle: image.NewNineSliceColor(color.NRGBA{100, 100, 100, 255}), + Hover: image.NewNineSliceColor(color.NRGBA{100, 100, 100, 255}), + } + + slidersButton := &widget.ButtonImage{ + Idle: image.NewNineSliceColor(color.NRGBA{255, 100, 100, 255}), + Hover: image.NewNineSliceColor(color.NRGBA{255, 100, 100, 255}), + Pressed: image.NewNineSliceColor(color.NRGBA{255, 100, 100, 255}), + } + + root := widget.NewContainer( + widget.ContainerOpts.Layout(widget.NewAnchorLayout()), + ) + + topLeft := widget.NewContainer( + // widget.ContainerOpts.BackgroundImage( + // image.NewNineSliceColor(colornames.Indigo), + // ), + widget.ContainerOpts.Layout(widget.NewRowLayout( + widget.RowLayoutOpts.Direction(widget.DirectionVertical), + widget.RowLayoutOpts.Spacing(5), + widget.RowLayoutOpts.Padding(widget.NewInsetsSimple(5)), + )), + widget.ContainerOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + VerticalPosition: widget.AnchorLayoutPositionStart, + HorizontalPosition: widget.AnchorLayoutPositionStart, + StretchVertical: true, + }), + widget.WidgetOpts.MinSize(80, 50), + ), + ) + + root.AddChild(topLeft) + + overPopLabel := widget.NewText( + widget.TextOpts.Text(fmt.Sprintf("Over Population Threshold: >%d", 5), &face, color.White), + widget.TextOpts.Position(widget.TextPositionStart, widget.TextPositionCenter), + widget.TextOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.RowLayoutData{ + Position: widget.RowLayoutPositionCenter, + }), + ), + ) + + topLeft.AddChild(overPopLabel) + + overPopSlider := widget.NewSlider( + // Set the slider orientation - n/s vs e/w + widget.SliderOpts.Orientation(widget.DirectionHorizontal), + // Set the minimum and maximum value for the slider + widget.SliderOpts.MinMax(0, 6), + // Set the current value of the slider, without triggering a change event + widget.SliderOpts.InitialCurrent(5), + widget.SliderOpts.WidgetOpts( + // Set the Widget to layout in the center on the screen + widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + HorizontalPosition: widget.AnchorLayoutPositionCenter, + VerticalPosition: widget.AnchorLayoutPositionCenter, + }), + // Set the widget's dimensions + widget.WidgetOpts.MinSize(100, 6), + ), + widget.SliderOpts.Images( + slidersTrack, + slidersButton, + ), + // Set the size of the handle + widget.SliderOpts.FixedHandleSize(6), + // Set the offset to display the track + widget.SliderOpts.TrackOffset(0), + // Set the size to move the handle + widget.SliderOpts.PageSizeFunc(func() int { + return 1 + }), + ) + topLeft.AddChild(overPopSlider) + + underPopLabel := widget.NewText( + widget.TextOpts.Text(fmt.Sprintf("Under Population Threshold: <%d", 5), &face, color.White), + widget.TextOpts.Position(widget.TextPositionStart, widget.TextPositionCenter), + widget.TextOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.RowLayoutData{ + Position: widget.RowLayoutPositionCenter, + }), + ), + ) + + topLeft.AddChild(underPopLabel) + + underPopSlider := widget.NewSlider( + // Set the slider orientation - n/s vs e/w + widget.SliderOpts.Orientation(widget.DirectionHorizontal), + // Set the minimum and maximum value for the slider + widget.SliderOpts.MinMax(0, 6), + // Set the current value of the slider, without triggering a change event + widget.SliderOpts.InitialCurrent(2), + widget.SliderOpts.WidgetOpts( + // Set the Widget to layout in the center on the screen + widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + HorizontalPosition: widget.AnchorLayoutPositionCenter, + VerticalPosition: widget.AnchorLayoutPositionCenter, + }), + // Set the widget's dimensions + widget.WidgetOpts.MinSize(100, 6), + ), + widget.SliderOpts.Images( + slidersTrack, + slidersButton, + ), + // Set the size of the handle + widget.SliderOpts.FixedHandleSize(6), + // Set the offset to display the track + widget.SliderOpts.TrackOffset(0), + // Set the size to move the handle + widget.SliderOpts.PageSizeFunc(func() int { + return 1 + }), + // Set the callback to call when the slider value is changed + widget.SliderOpts.ChangedHandler(func(args *widget.SliderChangedEventArgs) { + if args.Current == 0 { + underPopLabel.Label = "Under Population Threshold: (disabled)" + } else { + underPopLabel.Label = fmt.Sprintf("Under Population Threshold: <%d", args.Current) + } + if args.Current > overPopSlider.Current { + overPopSlider.Current = args.Current + } + _ = EventBus().Emit(SliderEvent{data: args, name: UnderPopulationSliderEvent}) + }), + ) + + overPopSlider.ChangedEvent.AddHandler(func(argsRaw any) { + args, ok := argsRaw.(*widget.SliderChangedEventArgs) + if !ok { + return + } + + if args.Current == 6 { + overPopLabel.Label = "Over Population Threshold: (disabled)" + } else { + overPopLabel.Label = fmt.Sprintf("Over Population Threshold: >%d ", args.Current) + } + + if args.Current < underPopSlider.Current { + underPopSlider.Current = args.Current + } + _ = EventBus().Emit(SliderEvent{data: args, name: OverPopulationSliderEvent}) + }) + + topLeft.AddChild(underPopSlider) + + reprodTartgeLabel := widget.NewText( + widget.TextOpts.Text(fmt.Sprintf("Reproduction Target: =%d", 2), &face, color.White), + widget.TextOpts.Position(widget.TextPositionCenter, widget.TextPositionCenter), + widget.TextOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.RowLayoutData{ + Position: widget.RowLayoutPositionStart, + }), + ), + ) + + topLeft.AddChild(reprodTartgeLabel) + + reprodTargetSlider := widget.NewSlider( + // Set the slider orientation - n/s vs e/w + widget.SliderOpts.Orientation(widget.DirectionHorizontal), + // Set the minimum and maximum value for the slider + widget.SliderOpts.MinMax(0, 6), + // Set the current value of the slider, without triggering a change event + widget.SliderOpts.InitialCurrent(2), + widget.SliderOpts.WidgetOpts( + // Set the Widget to layout in the center on the screen + widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + HorizontalPosition: widget.AnchorLayoutPositionStart, + VerticalPosition: widget.AnchorLayoutPositionCenter, + }), + // Set the widget's dimensions + widget.WidgetOpts.MinSize(100, 6), + ), + widget.SliderOpts.Images( + slidersTrack, + slidersButton, + ), + // Set the size of the handle + widget.SliderOpts.FixedHandleSize(6), + // Set the offset to display the track + // + widget.SliderOpts.TrackOffset(0), + // Set the size to move the handle + widget.SliderOpts.PageSizeFunc(func() int { + return 1 + }), + // Set the callback to call when the slider value is changed + widget.SliderOpts.ChangedHandler(func(args *widget.SliderChangedEventArgs) { + if args.Current > overPopSlider.Current || args.Current < underPopSlider.Current { + reprodTartgeLabel.Label = "Reproduction target: (disabled due to other parameters)" + } else { + reprodTartgeLabel.Label = fmt.Sprintf("Reproduction target: =%d", args.Current) + } + _ = EventBus().Emit(SliderEvent{data: args, name: ReproductionTargetEvent}) + }), + ) + + topLeft.AddChild(reprodTargetSlider) + + bottomRight := widget.NewContainer( + widget.ContainerOpts.Layout(widget.NewRowLayout( + widget.RowLayoutOpts.Direction(widget.DirectionVertical), + widget.RowLayoutOpts.Spacing(5), + widget.RowLayoutOpts.Padding(widget.NewInsetsSimple(5)), + )), + widget.ContainerOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + VerticalPosition: widget.AnchorLayoutPositionEnd, + HorizontalPosition: widget.AnchorLayoutPositionEnd, + }), + widget.WidgetOpts.MinSize(80, 50), + ), + ) + + root.AddChild(bottomRight) + + speedLabel := widget.NewText( + widget.TextOpts.Text("Speed: 0 ticks/second", &face, color.White), + widget.TextOpts.Position(widget.TextPositionStart, widget.TextPositionCenter), + widget.TextOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.RowLayoutData{ + Position: widget.RowLayoutPositionCenter, + }), + ), + ) + + bottomRight.AddChild(speedLabel) + + speedSlider := widget.NewSlider( + // Set the slider orientation - n/s vs e/w + widget.SliderOpts.Orientation(widget.DirectionHorizontal), + // Set the minimum and maximum value for the slider + widget.SliderOpts.MinMax(0, 10), + // Set the current value of the slider, without triggering a change event + widget.SliderOpts.InitialCurrent(1), + widget.SliderOpts.WidgetOpts( + // Set the Widget to layout in the center on the screen + widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + HorizontalPosition: widget.AnchorLayoutPositionCenter, + VerticalPosition: widget.AnchorLayoutPositionCenter, + }), + // Set the widget's dimensions + widget.WidgetOpts.MinSize(100, 6), + ), + widget.SliderOpts.Images( + slidersTrack, + slidersButton, + ), + // Set the size of the handle + widget.SliderOpts.FixedHandleSize(6), + // Set the offset to display the track + widget.SliderOpts.TrackOffset(0), + // Set the size to move the handle + widget.SliderOpts.PageSizeFunc(func() int { + return 1 + }), + // Set the callback to call when the slider value is changed + widget.SliderOpts.ChangedHandler(func(args *widget.SliderChangedEventArgs) { + speedLabel.Label = fmt.Sprintf("Speed: %d ticks/second", args.Current) + _ = EventBus().Emit(SliderEvent{data: args, name: SpeedSliderEvent}) + }), + ) + + bottomRight.AddChild(speedSlider) + + baseColor := colornames.Aliceblue + resetButton := widget.NewButton( + widget.ButtonOpts.Image(&widget.ButtonImage{ + Idle: image.NewBorderedNineSliceColor(baseColor, colornames.Black, 1), + Hover: image.NewBorderedNineSliceColor(mixColors(baseColor, colornames.Gainsboro, 0.2), colornames.Black, 1), + Pressed: image.NewBorderedNineSliceColor(mixColors(baseColor, colornames.Darkgray, 0.2), colornames.Black, 1), + }), + widget.ButtonOpts.Text("Reset", &face, &widget.ButtonTextColor{ + Idle: colornames.Purple, + }), + widget.ButtonOpts.WidgetOpts( + widget.WidgetOpts.LayoutData(widget.RowLayoutData{ + Stretch: true, + }), + ), + widget.ButtonOpts.TextPosition( + widget.TextPositionCenter, + widget.TextPositionCenter, + ), + widget.ButtonOpts.ClickedHandler(func(args *widget.ButtonClickedEventArgs) { + _ = EventBus().Emit(ButtonEvent{data: args, name: ResetButtonEvent}) + }), + ) + + bottomRight.AddChild(resetButton) + + return root +} + +type SliderEvent struct { + name string + data *widget.SliderChangedEventArgs +} + +// Data implements Event. +func (s SliderEvent) Data() any { + return s.data +} + +// Name implements Event. +func (s SliderEvent) Name() string { + return s.name +} + +type ButtonEvent struct { + name string + data *widget.ButtonClickedEventArgs +} + +// Data implements Event. +func (b ButtonEvent) Data() any { + return b.data +} + +// Name implements Event. +func (b ButtonEvent) Name() string { + return b.name +} + +func loadFont(size float64) (text.Face, error) { + s, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF)) + if err != nil { + log.Fatal(err) + return nil, err + } + + return &text.GoTextFace{ + Source: s, + Size: size, + }, nil +}