Compare commits
4
Commits
fd077d1ab1
...
8869ea437f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8869ea437f
|
||
|
|
fe4347d4ea
|
||
|
|
58fb23a668
|
||
|
|
f857cc18fd
|
@@ -0,0 +1,7 @@
|
||||
[[file:life.png]]
|
||||
|
||||
[[https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life][Conway's Game of Life]], written December 24-25, 2022.
|
||||
|
||||
Colors indicate change. Green for cells born, red for those deceased. Stable cells are black.
|
||||
|
||||
Press q to quit.
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/dwrz/life/internal/life"
|
||||
"code.chimeric.al/dwrz/life/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() {
|
||||
flag.Parse()
|
||||
|
||||
var log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
|
||||
// Setup the main context.
|
||||
// The context cancels on SIGINT or SIGTERM.
|
||||
ctx, stop := signal.NotifyContext(
|
||||
context.Background(),
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx, log); err != nil {
|
||||
log.Error("failed", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, log *slog.Logger) error {
|
||||
if *height < 0 {
|
||||
return fmt.Errorf("invalid height: %d", *height)
|
||||
}
|
||||
if *width < 0 {
|
||||
return fmt.Errorf("invalid width: %d", *width)
|
||||
}
|
||||
if *tick <= 0 {
|
||||
return fmt.Errorf("invalid tick: %d", *tick)
|
||||
}
|
||||
|
||||
// Setup workspace and log file.
|
||||
cdir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to determine user cache directory: %w", err,
|
||||
)
|
||||
}
|
||||
wdir := filepath.Join(cdir, "life")
|
||||
|
||||
if err := os.MkdirAll(wdir, os.ModeDir|0700); err != nil {
|
||||
return fmt.Errorf("failed to create tmp dir: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(wdir + "/log")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var gameLog = slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
|
||||
// Retrieve terminal info.
|
||||
t, err := terminal.New(os.Stdin.Fd())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get terminal attributes: %w", err)
|
||||
}
|
||||
size, err := t.Size()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get terminal size: %w", err)
|
||||
}
|
||||
if *height == 0 {
|
||||
rows := int(size.Rows)
|
||||
height = &rows
|
||||
}
|
||||
if *width == 0 {
|
||||
cols := int(size.Columns)
|
||||
width = &cols
|
||||
}
|
||||
|
||||
// Setup the game.
|
||||
var game = life.New(life.Parameters{
|
||||
Height: *height,
|
||||
In: os.Stdin,
|
||||
Log: gameLog,
|
||||
Out: os.Stdout,
|
||||
Terminal: t,
|
||||
Tick: time.Duration(*tick) * time.Millisecond,
|
||||
Width: *width,
|
||||
})
|
||||
|
||||
// Run the game.
|
||||
return game.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package life
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/dwrz/life/pkg/color"
|
||||
"code.chimeric.al/dwrz/life/pkg/terminal"
|
||||
"code.chimeric.al/dwrz/life/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 *slog.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 *slog.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, 1),
|
||||
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 {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Setup the terminal.
|
||||
if err := l.terminal.SetRaw(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to set terminal attributes: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up before exit.
|
||||
defer l.quit()
|
||||
|
||||
l.out.WriteString(terminal.ClearScreen)
|
||||
l.out.WriteString(terminal.CursorHide)
|
||||
l.render()
|
||||
|
||||
// Start reading user input.
|
||||
// The reader may be blocked in a read when the game ends.
|
||||
// The reader stops when the process stops.
|
||||
go func() {
|
||||
if err := l.input.Run(ctx); err != nil &&
|
||||
!errors.Is(err, context.Canceled) {
|
||||
l.errs <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop.
|
||||
// TODO: handle sigwinch; resize the world on terminal resize.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
|
||||
case err := <-l.errs:
|
||||
return err
|
||||
|
||||
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(
|
||||
"failed to reset terminal attributes",
|
||||
slog.Any("error", 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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
ClearScreen = "\u001Bc"
|
||||
CursorHide = "\u001B[?25l"
|
||||
CursorShow = "\u001B[?25h"
|
||||
CursorTopLeft = "\u001B[H"
|
||||
EraseLine = "\u001B[K"
|
||||
|
||||
SetCursorFmt = "\u001B[%d;%dH"
|
||||
)
|
||||
|
||||
type Terminal struct {
|
||||
fd uintptr
|
||||
termios *syscall.Termios
|
||||
}
|
||||
|
||||
type Size struct {
|
||||
Rows uint16
|
||||
Columns uint16
|
||||
XPixels uint16
|
||||
YPixels uint16
|
||||
}
|
||||
|
||||
func New(fd uintptr) (*Terminal, error) {
|
||||
var t = &Terminal{
|
||||
fd: fd,
|
||||
termios: &syscall.Termios{},
|
||||
}
|
||||
|
||||
if _, _, err := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
fd,
|
||||
syscall.TCGETS,
|
||||
uintptr(unsafe.Pointer(t.termios)),
|
||||
); err != 0 {
|
||||
return nil, fmt.Errorf("ioctl syscall: %w", err)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Reset the terminal into the original termios.
|
||||
func (t *Terminal) Reset() error {
|
||||
if _, _, err := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
t.fd,
|
||||
uintptr(syscall.TCSETS),
|
||||
uintptr(unsafe.Pointer(t.termios)),
|
||||
); err != 0 {
|
||||
return fmt.Errorf("ioctl syscall: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRaw enables raw mode.
|
||||
func (t *Terminal) SetRaw() error {
|
||||
var termios = *t.termios
|
||||
termios.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON
|
||||
termios.Oflag &^= syscall.OPOST
|
||||
termios.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN
|
||||
termios.Cflag &^= syscall.CSIZE | syscall.PARENB
|
||||
termios.Cflag |= syscall.CS8
|
||||
termios.Cc[syscall.VMIN] = 1
|
||||
termios.Cc[syscall.VTIME] = 0
|
||||
|
||||
if _, _, err := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
t.fd,
|
||||
uintptr(syscall.TCSETS),
|
||||
uintptr(unsafe.Pointer(&termios)),
|
||||
); err != 0 {
|
||||
return fmt.Errorf("ioctl syscall: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Terminal) Size() (*Size, error) {
|
||||
var size = &Size{}
|
||||
if _, _, err := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
t.fd,
|
||||
syscall.TIOCGWINSZ,
|
||||
uintptr(unsafe.Pointer(size)),
|
||||
); err != 0 {
|
||||
return nil, fmt.Errorf("ioctl syscall: %w", err)
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
Reference in New Issue
Block a user