Initial commit

implemented the game of life variation with hexagonal grid
This commit is contained in:
2025-12-16 11:33:00 +00:00
commit ea5b5c4e75
17 changed files with 1229 additions and 0 deletions

20
README.md Normal file
View File

@@ -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
```

25
go.mod Normal file
View File

@@ -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
)

40
go.sum Normal file
View File

@@ -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=

183
grid/hex.go Normal file
View File

@@ -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},
}
}

83
grid/hex_test.go Normal file
View File

@@ -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)
}
})
}
}

169
main.go Normal file
View File

@@ -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)
}
}

37
math/comp.go Normal file
View File

@@ -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
}
}

75
math/comp_test.go Normal file
View File

@@ -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)
}
})
}
}

5
math/const.go Normal file
View File

@@ -0,0 +1,5 @@
package math
import "math"
const Pi = math.Pi

39
math/ops.go Normal file
View File

@@ -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)))
}

47
math/ops_test.go Normal file
View File

@@ -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)
}
}

19
math/pair.go Normal file
View File

@@ -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)
}

30
ticker.go Normal file
View File

@@ -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
}
}

25
ui/colors.go Normal file
View File

@@ -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),
}
}

58
ui/event_bus.go Normal file
View File

@@ -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
}

9
ui/events.go Normal file
View File

@@ -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"
)

365
ui/root.go Normal file
View File

@@ -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
}