Compare commits

...
4 Commits
Author SHA1 Message Date
dwrz 8869ea437f Add README 2026-10-25 00:59:59 +00:00
dwrz fe4347d4ea Add life cmd 2026-10-25 00:59:46 +00:00
dwrz 58fb23a668 Add life pkg 2026-10-25 00:59:39 +00:00
dwrz f857cc18fd Add terminal pkg 2026-10-25 00:59:31 +00:00
5 changed files with 477 additions and 0 deletions
+7
View File
@@ -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.
+113
View File
@@ -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)
}
+259
View File
@@ -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
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+98
View File
@@ -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
}