commit dc4abee56e21a9f91a9b6d14d18308170ea6abda
parent 3d7e5981589209b7c6ad20d07bf804250b147804
Author: dwrz <dwrz@dwrz.net>
Date: Sat, 24 Dec 2022 16:16:39 +0000
Add game of life
Diffstat:
A | cmd/life/main.go | | | 109 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | pkg/life/life.go | | | 248 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 357 insertions(+), 0 deletions(-)
diff --git a/cmd/life/main.go b/cmd/life/main.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "context"
+ "flag"
+
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "code.dwrz.net/src/pkg/life"
+ "code.dwrz.net/src/pkg/log"
+ "code.dwrz.net/src/pkg/terminal"
+)
+
+var (
+ height = flag.Int("h", 0, "height")
+ width = flag.Int("w", 0, "width")
+ tick = flag.Int("t", 100, "ms between turns")
+)
+
+func main() {
+ var l = log.New(os.Stderr)
+
+ // Parse flags.
+ flag.Parse()
+ if *height < 0 {
+ l.Error.Fatalf("invalid height: %d", *height)
+ }
+ if *width < 0 {
+ l.Error.Fatalf("invalid width: %d", *width)
+ }
+ if *tick <= 0 {
+ l.Error.Fatalf("invalid tick: %d", *tick)
+ }
+
+ // Setup the main context.
+ ctx, cancel := context.WithCancel(context.Background())
+
+ // Setup workspace and log file.
+ cdir, err := os.UserCacheDir()
+ if err != nil {
+ l.Error.Fatalf(
+ "failed to determine user cache directory: %v", err,
+ )
+ }
+ wdir := filepath.Join(cdir, "life")
+
+ if err := os.MkdirAll(wdir, os.ModeDir|0700); err != nil {
+ l.Error.Fatalf("failed to create tmp dir: %v", err)
+ }
+
+ f, err := os.Create(wdir + "/log")
+ if err != nil {
+ l.Error.Fatalf("failed to create log file: %v", err)
+ }
+ defer f.Close()
+
+ // Retrieve terminal info.
+ t, err := terminal.New(os.Stdin.Fd())
+ if err != nil {
+ l.Error.Fatalf("failed to get terminal attributes: %v", err)
+ }
+ size, err := t.Size()
+ if err != nil {
+ l.Error.Fatalf("failed to get terminal size: %v", err)
+ }
+ if *height == 0 {
+ rows := int(size.Rows)
+ height = &rows
+ }
+ if *width == 0 {
+ cols := int(size.Columns) + 1
+ width = &cols
+ }
+
+ // TODO: refactor; handle sigwinch.
+ go func() {
+ var signals = make(chan os.Signal, 1)
+ signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
+
+ // Block until we receive a signal.
+ s := <-signals
+ l.Debug.Printf("received signal: %s", s)
+
+ cancel()
+ }()
+
+ // Setup the game.
+ var game = life.New(life.Parameters{
+ Height: *height,
+ In: os.Stdin,
+ Log: log.New(f),
+ Out: os.Stdout,
+ Terminal: t,
+ Tick: time.Duration(*tick) * time.Millisecond,
+ Width: *width,
+ })
+ if err != nil {
+ l.Error.Fatalf("failed to create game: %v", err)
+ }
+
+ // Run the game.
+ if err := game.Run(ctx); err != nil {
+ l.Error.Fatal(err)
+ }
+}
diff --git a/pkg/life/life.go b/pkg/life/life.go
@@ -0,0 +1,248 @@
+package life
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "math/rand"
+ "os"
+ "time"
+
+ "code.dwrz.net/src/pkg/color"
+ "code.dwrz.net/src/pkg/log"
+ "code.dwrz.net/src/pkg/terminal"
+ "code.dwrz.net/src/pkg/terminal/input"
+)
+
+const (
+ alive = color.BackgroundBlack + " " + color.Reset
+ born = color.BackgroundGreen + " " + color.Reset
+ died = color.BackgroundRed + " " + color.Reset
+ dead = " "
+)
+
+var random = rand.New(rand.NewSource(time.Now().UnixNano()))
+
+type Life struct {
+ errs chan error
+ events chan *input.Event
+ height int
+ in *os.File
+ input *input.Reader
+ last [][]bool
+ log *log.Logger
+ out *os.File
+ terminal *terminal.Terminal
+ tick time.Duration
+ ticker *time.Ticker
+ turn uint
+ width int
+ world [][]bool
+}
+
+type Parameters struct {
+ Height int
+ In *os.File
+ Log *log.Logger
+ Out *os.File
+ Terminal *terminal.Terminal
+ Tick time.Duration
+ Width int
+}
+
+func New(p Parameters) *Life {
+ // Account for two spaces to represent each cell.
+ p.Width /= 2
+ // Account one line to render the turn count.
+ p.Height -= 1
+
+ var life = &Life{
+ errs: make(chan error),
+ events: make(chan *input.Event),
+ height: p.Height,
+ in: p.In,
+ last: make([][]bool, p.Height),
+ log: p.Log,
+ out: p.Out,
+ terminal: p.Terminal,
+ tick: p.Tick,
+ ticker: time.NewTicker(p.Tick),
+ width: p.Width,
+ world: make([][]bool, p.Height),
+ }
+ for i := range life.world {
+ life.last[i] = make([]bool, p.Width)
+ life.world[i] = make([]bool, p.Width)
+ }
+ for i := 0; i < (p.Height * p.Width / 4); i++ {
+ x := random.Intn(p.Width)
+ y := random.Intn(p.Height)
+
+ life.world[y][x] = true
+ }
+
+ life.input = input.New(input.Parameters{
+ Chan: life.events,
+ In: os.Stdin,
+ Log: life.log,
+ })
+
+ return life
+}
+
+func (l *Life) Run(ctx context.Context) error {
+ // Setup the terminal.
+ if err := l.terminal.SetRaw(); err != nil {
+ return fmt.Errorf(
+ "failed to set terminal attributes: %v", err,
+ )
+ }
+
+ // Clean up before exit.
+ defer l.quit()
+
+ // TODO: refactor to use a terminal output package.
+ l.out.WriteString(terminal.ClearScreen)
+ l.out.WriteString(terminal.CursorHide)
+ l.render()
+
+ // Start reading user input.
+ go func() {
+ if err := l.input.Run(ctx); err != nil {
+ l.errs <- err
+ }
+ }()
+
+ // Main loop.
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+
+ case <-l.ticker.C:
+ l.step()
+ l.render()
+
+ case event := <-l.events:
+ switch event.Rune {
+ case 'q': // Quit
+ return nil
+ }
+ }
+ }
+}
+
+func (l *Life) alive(x, y int) bool {
+ // Wrap coordinates toroidally.
+ x += l.width
+ x %= l.width
+ y += l.height
+ y %= l.height
+
+ return l.world[y][x]
+}
+
+func (l *Life) born(x, y int) bool {
+ // Wrap coordinates toroidally.
+ x += l.width
+ x %= l.width
+ y += l.height
+ y %= l.height
+
+ return !l.last[y][x] && l.world[y][x]
+}
+
+func (l *Life) died(x, y int) bool {
+ // Wrap coordinates toroidally.
+ x += l.width
+ x %= l.width
+ y += l.height
+ y %= l.height
+
+ return l.last[y][x] && !l.world[y][x]
+}
+
+func (l *Life) next(x, y int) bool {
+ // Count the numbers of living neighbors.
+ var alive = 0
+ for v := -1; v <= 1; v++ {
+ for h := -1; h <= 1; h++ {
+ // Ignore self.
+ if h == 0 && v == 0 {
+ continue
+ }
+ if l.alive(x+v, y+h) {
+ alive++
+ }
+ }
+ }
+
+ // Determine the next state.
+ switch alive {
+ case 3: // Turn on.
+ return true
+ case 2: // Maintain state.
+ return l.alive(x, y)
+ default: // Turn off.
+ return false
+ }
+}
+
+func (l *Life) quit() {
+ l.out.Write([]byte(terminal.CursorShow))
+
+ if err := l.terminal.Reset(); err != nil {
+ l.log.Error.Printf(
+ "failed to reset terminal attributes: %v", err,
+ )
+ }
+}
+
+func (l *Life) render() {
+ var buf bytes.Buffer
+
+ buf.WriteString(terminal.CursorTopLeft)
+
+ for y := 0; y < l.height; y++ {
+ for x := 0; x < l.width; x++ {
+ switch {
+ case l.born(x, y):
+ buf.WriteString(born)
+ case l.died(x, y):
+ buf.WriteString(died)
+ case l.alive(x, y):
+ buf.WriteString(alive)
+ default:
+ buf.WriteString(dead)
+
+ }
+ }
+ buf.WriteString("\r\n")
+ }
+ fmt.Fprintf(
+ &buf, "%sTurn:%s %d\r\n",
+ color.Bold, color.Reset, l.turn,
+ )
+
+ l.out.Write(buf.Bytes())
+}
+
+func (l *Life) step() {
+ // Create the next world.
+ var next = make([][]bool, l.height)
+ for i := range next {
+ next[i] = make([]bool, l.width)
+ }
+
+ // Set the new cells based on the existing cells.
+ for y := 0; y < l.height; y++ {
+ for x := 0; x < l.width; x++ {
+ next[y][x] = l.next(x, y)
+ }
+ }
+
+ // Increment the turn and set the next world.
+ l.turn++
+ l.last = l.world
+ l.world = next
+}