works, with various options

This commit is contained in:
2024-05-21 19:01:08 +02:00
parent b03e2d57e9
commit 3837be4f53
5 changed files with 366 additions and 63 deletions

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# Conway's game of life
I wanted to play around a little bit with GOL in golang and here's the
result. It's a simple game using
[ebitengine](https://github.com/hajimehoshi/ebiten/).
# Build and install
Just execute: `go build .` and use the resulting executable.
You'll need the golang toolchain.
# Usage
The game has a couple of commandline options:
```default
Usage of ./gameoflife:
-c, --cellsize int cell size in pixels (default 8)
-d, --debug show debug info
-D, --density int density of random cells (default 10)
-e, --empty start with an empty screen
-H, --height int grid height in cells (default 40)
-i, --invert invert colors (dead cell: black)
-r, --rule string game rule (default "B3/S23")
-s, --show-evolution show evolution tracks
-t, --tps int game speed in ticks per second (default 60)
-v, --version show version
-W, --width int grid width in cells (default 40)
```
While it runs, there are a couple of commands you can use:
* left mouse click: set a cell to alife
* right mouse click: set a cell to dead
* space: pause or resume the game
* q: quit
* up arrow: speed up
* down arrow: slow down
* page up: speed up more
* page down: slow down more
# Report bugs
[Please open an issue](https://github.com/TLINDEN/gameoflife/issues). Thanks!
# License
This work is licensed under the terms of the General Public Licens
version 3.
# Author
Copyleft (c) 2024 Thomas von Dein

9
TODO.md Normal file
View File

@@ -0,0 +1,9 @@
https://conwaylife.com/wiki/Run_Length_Encoded
e.g.:
https://github.com/nhoffmann/life/tree/master/rle
https://github.com/sachaos/go-life/tree/master/format/rle
rle files:
https://catagolue.hatsya.com/object/xq2_32mmgozg0igke72z1n2q1z0qgm1z31i2bsogzggqq261z1/b3s23
https://copy.sh/life/examples/

15
go.mod
View File

@@ -2,14 +2,21 @@ module gameoflife
go 1.22 go 1.22
require github.com/hajimehoshi/ebiten/v2 v2.7.3 require (
github.com/hajimehoshi/ebiten/v2 v2.7.4
github.com/spf13/pflag v1.0.5
github.com/tinne26/etxt v0.0.8
github.com/tinne26/fonts/liberation/lbrtserif v0.0.0-20230317183620-0b634734e4ec
)
require ( require (
github.com/alecthomas/repr v0.4.0 // indirect github.com/alecthomas/repr v0.4.0 // indirect
github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8 // indirect github.com/ebitengine/gomobile v0.0.0-20240518074828-e86332849895 // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/purego v0.7.0 // indirect github.com/ebitengine/purego v0.7.0 // indirect
github.com/jezek/xgb v1.1.1 // indirect github.com/jezek/xgb v1.1.1 // indirect
golang.org/x/sync v0.6.0 // indirect golang.org/x/image v0.16.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
) )

28
go.sum
View File

@@ -1,18 +1,26 @@
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8 h1:5e8X7WEdOWrjrKvgaWF6PRnDvJicfrkEnwAkWtMN74g= github.com/ebitengine/gomobile v0.0.0-20240518074828-e86332849895 h1:48bCqKTuD7Z0UovDfvpCn7wZ0GUZ+yosIteNDthn3FU=
github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8/go.mod h1:tWboRRNagZwwwis4QIgEFG1ZNFwBJ3LAhSLAXAAxobQ= github.com/ebitengine/gomobile v0.0.0-20240518074828-e86332849895/go.mod h1:XZdLv05c5hOZm3fM2NlJ92FyEZjnslcMcNRrhxs8+8M=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= 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/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 h1:HPZpl61edMGCEW6XK2nsR6+7AnJ3unUxpTZBkkIXnMc=
github.com/ebitengine/purego v0.7.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/ebitengine/purego v0.7.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/hajimehoshi/ebiten/v2 v2.7.3 h1:lDpj8KbmmjzwD19rsjXNkyelicu0XGvklZW6/tjrgNs= github.com/hajimehoshi/ebiten/v2 v2.7.4 h1:X+heODRQ3Ie9F9QFjm24gEZqQd5FSfR9XuT2XfHwgf8=
github.com/hajimehoshi/ebiten/v2 v2.7.3/go.mod h1:1vjyPw+h3n30rfTOpIsbWRXSxZ0Oz1cYc6Tq/2DKoQg= github.com/hajimehoshi/ebiten/v2 v2.7.4/go.mod h1:H2pHVgq29rfm5yeQ7jzWOM3VHsjo7/AyucODNLOhsVY=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= github.com/tinne26/etxt v0.0.8 h1:rjb58jkMkapRGLmhBMWnT76E/nMTXC5P1Q956BRZkoc=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= github.com/tinne26/etxt v0.0.8/go.mod h1:QM/hlNkstsKC39elTFNKAR34xsMb9QoVosf+g9wlYxM=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= github.com/tinne26/fonts/liberation/lbrtserif v0.0.0-20230317183620-0b634734e4ec h1:CUSt85il4uQxLjlVhup44P7gpaZmkYooIHmCLjq85vg=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/tinne26/fonts/liberation/lbrtserif v0.0.0-20230317183620-0b634734e4ec/go.mod h1:4BN4bFDBeF9+E97yjko9Pe7x8WgY4Ek6oiOYa1KDgpE=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

321
main.go
View File

@@ -1,12 +1,26 @@
package main package main
import ( import (
"fmt"
"image/color" "image/color"
"log" "log"
"math/rand" "math/rand"
"os"
"strconv"
"strings"
"github.com/alecthomas/repr"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
"github.com/spf13/pflag"
)
const (
VERSION = "v0.0.1"
Alive = 1
Dead = 0
) )
type Grid struct { type Grid struct {
@@ -14,18 +28,54 @@ type Grid struct {
} }
type Game struct { type Game struct {
Grids []*Grid // 2 grids: one current, one next Grids []*Grid // 2 grids: one current, one next
Index int // points to current grid History *Grid
Width, Height, Cellsize int Index int // points to current grid
ScreenWidth, ScreenHeight int Width, Height, Cellsize, Density int
Black, White color.RGBA ScreenWidth, ScreenHeight int
Generations int
Black, White, Grey, Beige color.RGBA
Speed int
Debug, Paused, Empty, Invert, ShowEvolution bool
Rule *Rule
} }
func (game *Game) Layout(outsideWidth, outsideHeight int) (int, int) { func (game *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return game.ScreenWidth, game.ScreenHeight return game.ScreenWidth, game.ScreenHeight
} }
func (game *Game) Update() error { func (game *Game) CheckRule(state, neighbors int) int {
var nextstate int
// The standard Game of Life is symbolized in rule-string notation
// as B3/S23 (23/3 here). A cell is born if it has exactly three
// neighbors, survives if it has two or three living neighbors,
// and dies otherwise. The first number, or list of numbers, is
// what is required for a dead cell to be born.
if state == 0 && Contains(game.Rule.Birth, neighbors) {
nextstate = 1
} else if state == 1 && Contains(game.Rule.Death, neighbors) {
nextstate = 1
} else {
nextstate = 0
}
return nextstate
}
// find an item in a list, generic variant
func Contains[E comparable](s []E, v E) bool {
for _, vs := range s {
if v == vs {
return true
}
}
return false
}
func (game *Game) UpdateCells() {
// compute cells // compute cells
next := game.Index ^ 1 // next grid index, we just xor 0|1 to 1|0 next := game.Index ^ 1 // next grid index, we just xor 0|1 to 1|0
@@ -33,62 +83,191 @@ func (game *Game) Update() error {
for x := 0; x < game.Width; x++ { for x := 0; x < game.Width; x++ {
state := game.Grids[game.Index].Data[y][x] // 0|1 == dead or alive state := game.Grids[game.Index].Data[y][x] // 0|1 == dead or alive
neighbors := CountNeighbors(game, x, y) // alive neighbor count neighbors := CountNeighbors(game, x, y) // alive neighbor count
var nextstate int
// the actual game of life rules // actually apply the current rules
if state == 0 && neighbors == 3 { nextstate := game.CheckRule(state, neighbors)
nextstate = 1
} else if state == 1 && (neighbors < 2 || neighbors > 3) {
nextstate = 0
} else {
nextstate = state
}
// change state of current cell in next grid // change state of current cell in next grid
game.Grids[next].Data[y][x] = nextstate game.Grids[next].Data[y][x] = nextstate
if state == 1 {
game.History.Data[y][x] = 1
}
} }
} }
// switch grid for rendering // switch grid for rendering
game.Index ^= 1 game.Index ^= 1
// global counter
game.Generations++
}
// a GOL rule
type Rule struct {
Birth []int
Death []int
}
// parse one part of a GOL rule into rule slice
func NumbersToList(numbers string) []int {
list := []int{}
items := strings.Split(numbers, "")
for _, item := range items {
num, err := strconv.Atoi(item)
if err != nil {
log.Fatalf("failed to parse game rule part <%s>: %s", numbers, err)
}
list = append(list, num)
}
return list
}
// parse GOL rule, used in CheckRule()
func ParseGameRule(rule string) *Rule {
parts := strings.Split(rule, "/")
if len(parts) < 2 {
log.Fatalf("Invalid game rule <%s>", rule)
}
golrule := &Rule{}
for _, part := range parts {
if part[0] == 'B' {
golrule.Birth = NumbersToList(part[1:])
} else {
golrule.Death = NumbersToList(part[1:])
}
}
return golrule
}
// check user input
func (game *Game) CheckInput() {
if inpututil.IsKeyJustPressed(ebiten.KeyQ) {
os.Exit(0)
}
if inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
game.Paused = !game.Paused
}
if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
ToggleCell(game, Alive)
game.Paused = true // drawing while running makes no sense
}
if ebiten.IsMouseButtonPressed(ebiten.MouseButtonRight) {
ToggleCell(game, Dead)
game.Paused = true // drawing while running makes no sense
}
if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) {
if game.Speed > 1 {
game.Speed--
ebiten.SetTPS(game.Speed)
}
}
if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) {
if game.Speed < 120 {
game.Speed++
ebiten.SetTPS(game.Speed)
}
}
if inpututil.IsKeyJustPressed(ebiten.KeyPageDown) {
switch {
case game.Speed > 5:
game.Speed -= 5
case game.Speed <= 5:
game.Speed = 1
}
ebiten.SetTPS(game.Speed)
}
if inpututil.IsKeyJustPressed(ebiten.KeyPageUp) {
if game.Speed <= 115 {
game.Speed += 5
ebiten.SetTPS(game.Speed)
}
}
}
func (game *Game) Update() error {
game.CheckInput()
if !game.Paused {
game.UpdateCells()
}
return nil return nil
} }
// fill a cell with the given color
func FillCell(screen *ebiten.Image, x, y, cellsize int, col color.RGBA) {
vector.DrawFilledRect(
screen,
float32(x*cellsize+1),
float32(y*cellsize+1),
float32(cellsize-1),
float32(cellsize-1),
col, false,
)
}
// set a cell to alive or dead
func ToggleCell(game *Game, alive int) {
xPX, yPX := ebiten.CursorPosition()
x := xPX / game.Cellsize
y := yPX / game.Cellsize
//fmt.Printf("cell at %d,%d\n", x, y)
game.Grids[game.Index].Data[y][x] = alive
game.History.Data[y][x] = 1
}
// draw the new grid state
func (game *Game) Draw(screen *ebiten.Image) { func (game *Game) Draw(screen *ebiten.Image) {
// we fill the whole screen with a background color, the cells
// themselfes will be 1px smaller as their nominal size, producing
// a nice grey grid with grid lines
screen.Fill(game.Grey)
for y := 0; y < game.Height; y++ { for y := 0; y < game.Height; y++ {
for x := 0; x < game.Width; x++ { for x := 0; x < game.Width; x++ {
currentcolor := game.White switch game.Grids[game.Index].Data[y][x] {
if game.Grids[game.Index].Data[y][x] == 1 { case 1:
currentcolor = game.Black FillCell(screen, x, y, game.Cellsize, game.Black)
} case 0:
if game.History.Data[y][x] == 1 && game.ShowEvolution {
vector.DrawFilledRect(screen, FillCell(screen, x, y, game.Cellsize, game.Beige)
float32(x*game.Cellsize), } else {
float32(y*game.Cellsize), FillCell(screen, x, y, game.Cellsize, game.White)
float32(game.Cellsize), }
float32(game.Cellsize),
currentcolor, false)
if currentcolor == game.White {
// draw black
vector.DrawFilledRect(screen,
float32(x*game.Cellsize),
float32(y*game.Cellsize),
float32(game.Cellsize),
float32(game.Cellsize),
game.Black, false)
// then fill with 1px lesser rect in white
// thus creating grid lines
vector.DrawFilledRect(screen,
float32(x*game.Cellsize+1),
float32(y*game.Cellsize+1),
float32(game.Cellsize-1),
float32(game.Cellsize-1),
game.White, false)
} }
} }
} }
if game.Debug {
paused := ""
if game.Paused {
paused = "-- paused --"
}
ebitenutil.DebugPrint(
screen,
fmt.Sprintf("FPS: %d, Generations: %d %s",
game.Speed, game.Generations, paused),
)
}
} }
func (game *Game) Init() { func (game *Game) Init() {
@@ -98,12 +277,19 @@ func (game *Game) Init() {
grid := &Grid{Data: make([][]int, game.Height)} grid := &Grid{Data: make([][]int, game.Height)}
gridb := &Grid{Data: make([][]int, game.Height)} gridb := &Grid{Data: make([][]int, game.Height)}
history := &Grid{Data: make([][]int, game.Height)}
for y := 0; y < game.Height; y++ { for y := 0; y < game.Height; y++ {
grid.Data[y] = make([]int, game.Width) grid.Data[y] = make([]int, game.Width)
gridb.Data[y] = make([]int, game.Width) gridb.Data[y] = make([]int, game.Width)
for x := 0; x < game.Width; x++ { history.Data[y] = make([]int, game.Width)
grid.Data[y][x] = rand.Intn(2) if !game.Empty {
for x := 0; x < game.Width; x++ {
if rand.Intn(game.Density) == 1 {
history.Data[y][x] = 1
grid.Data[y][x] = 1
}
}
} }
} }
@@ -112,12 +298,23 @@ func (game *Game) Init() {
gridb, gridb,
} }
game.History = history
game.Black = color.RGBA{0, 0, 0, 0xff} game.Black = color.RGBA{0, 0, 0, 0xff}
game.White = color.RGBA{0xff, 0xff, 0xff, 0xff} game.White = color.RGBA{200, 200, 200, 0xff}
game.Grey = color.RGBA{128, 128, 128, 0xff}
game.Beige = color.RGBA{0xff, 0xf8, 0xdc, 0xff}
if game.Invert {
game.White = color.RGBA{0, 0, 0, 0xff}
game.Black = color.RGBA{200, 200, 200, 0xff}
game.Beige = color.RGBA{0x8b, 0x1a, 0x1a, 0xff}
}
game.Index = 0 game.Index = 0
} }
// count the living neighbors of a cell
func CountNeighbors(game *Game, x, y int) int { func CountNeighbors(game *Game, x, y int) int {
sum := 0 sum := 0
@@ -139,15 +336,41 @@ func CountNeighbors(game *Game, x, y int) int {
} }
func main() { func main() {
game := &Game{Width: 180, Height: 160, Cellsize: 15} game := &Game{}
showversion := false
var rule string
pflag.IntVarP(&game.Width, "width", "W", 40, "grid width in cells")
pflag.IntVarP(&game.Height, "height", "H", 40, "grid height in cells")
pflag.IntVarP(&game.Cellsize, "cellsize", "c", 8, "cell size in pixels")
pflag.IntVarP(&game.Speed, "tps", "t", 60, "game speed in ticks per second")
pflag.IntVarP(&game.Density, "density", "D", 10, "density of random cells")
pflag.StringVarP(&rule, "rule", "r", "B3/S23", "game rule")
pflag.BoolVarP(&showversion, "version", "v", false, "show version")
pflag.BoolVarP(&game.Debug, "debug", "d", false, "show debug info")
pflag.BoolVarP(&game.Empty, "empty", "e", false, "start with an empty screen")
pflag.BoolVarP(&game.Invert, "invert", "i", false, "invert colors (dead cell: black)")
pflag.BoolVarP(&game.ShowEvolution, "show-evolution", "s", false, "show evolution tracks")
pflag.Parse()
if showversion {
fmt.Printf("This is gameoflife version %s\n", VERSION)
os.Exit(0)
}
game.Rule = ParseGameRule(rule)
repr.Print(game.Rule.Birth)
repr.Print(game.Rule.Death)
game.Init() game.Init()
ebiten.SetWindowSize(game.ScreenWidth, game.ScreenHeight) ebiten.SetWindowSize(game.ScreenWidth, game.ScreenHeight)
ebiten.SetWindowTitle("Game of life") ebiten.SetWindowTitle("Game of life")
ebiten.SetTPS(30) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
ebiten.SetTPS(game.Speed)
if err := ebiten.RunGame(game); err != nil { if err := ebiten.RunGame(game); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }