diff --git a/internal/life/life.go b/internal/life/life.go new file mode 100644 index 0000000..c6caa93 --- /dev/null +++ b/internal/life/life.go @@ -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 +}