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