From b82b14dd7c53b643999a538b88eaf34042040f04 Mon Sep 17 00:00:00 2001 From: dwrz Date: Sun, 25 Oct 2026 02:51:43 +0000 Subject: [PATCH] Add minotaur pkg --- internal/minotaur/minotaur.go | 261 ++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 internal/minotaur/minotaur.go diff --git a/internal/minotaur/minotaur.go b/internal/minotaur/minotaur.go new file mode 100644 index 0000000..f8ff162 --- /dev/null +++ b/internal/minotaur/minotaur.go @@ -0,0 +1,261 @@ +package minotaur + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "time" + + "code.chimeric.al/dwrz/minotaur/pkg/color" + "code.chimeric.al/dwrz/minotaur/pkg/maze" + "code.chimeric.al/dwrz/minotaur/pkg/maze/direction" + "code.chimeric.al/dwrz/minotaur/pkg/maze/position" + "code.chimeric.al/dwrz/minotaur/pkg/terminal" + "code.chimeric.al/dwrz/minotaur/pkg/terminal/input" +) + +const ( + // Game over messages. + slain = "You were slain by the Minotaur. Score: %v.\r\n" + escaped = "You escaped the labyrinth. Score: %v.\r\n" + + // Blocks for rendering. + end = color.BackgroundGreen + " " + color.Reset + minotaur = color.BackgroundYellow + " " + color.Reset + passage = " " + theseus = color.BackgroundMagenta + " " + color.Reset + solution = color.BackgroundBlue + " " + color.Reset + wall = color.BackgroundBlack + " " + color.Reset +) + +type Game struct { + errs chan error + events chan *input.Event + input *input.Reader + log *slog.Logger + maze *maze.Maze + out *os.File + start time.Time + terminal *terminal.Terminal + ticker *time.Ticker + + // Maze positions. + end position.Position + theseus position.Position + minotaur position.Position + solution []position.Position +} + +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) (*Game, error) { + // Convert terminal columns (p.Width) into maze cell columns. + // Each cell renders as two columns (two spaces) plus two columns for + // its right wall or passage, and the left border uses two columns: + // rendered columns = 4*width + 2. + if p.Width%4 == 0 { + p.Width -= 1 + } + p.Width /= 4 + + // Convert terminal rows (p.Height) into maze cell rows. + // Each cell renders as one row plus one row for its bottom wall or + // passage, the top border uses one row, and one row remains at the + // bottom so the terminal does not scroll: + // rendered rows = 2*height + 1. + p.Height = (p.Height - 2) / 2 + + // If either the terminal width or height are zero, + // then the terminal is too small to render the maze. + if p.Height <= 0 || p.Width <= 0 { + return nil, fmt.Errorf("terminal too small") + } + + var g = &Game{ + errs: make(chan error, 1), + events: make(chan *input.Event), + log: p.Log, + out: p.Out, + terminal: p.Terminal, + ticker: time.NewTicker(p.Tick), + } + + maze, err := maze.New(maze.Parameters{ + Height: p.Height, + Width: p.Width, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate maze: %w", err) + } + g.maze = maze + + g.input = input.New(input.Parameters{ + Chan: g.events, + In: p.In, + Log: g.log, + }) + + // Set start and end. + g.theseus = position.Random(p.Width, p.Height) + g.end = position.Random(p.Width, p.Height) + g.minotaur = position.Random(p.Width, p.Height) + + return g, nil +} + +func (g *Game) Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Setup the terminal. + if err := g.terminal.SetRaw(); err != nil { + return fmt.Errorf( + "failed to set terminal attributes: %w", err, + ) + } + + // Clean up before exit. + defer g.quit() + + g.out.WriteString(terminal.ClearScreen) + g.out.WriteString(terminal.CursorHide) + g.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 := g.input.Run(ctx); err != nil && + !errors.Is(err, context.Canceled) { + g.errs <- err + } + }() + + // Main loop. + g.start = time.Now() + for { + // Check for game over conditions. + switch { + case g.escaped(): + fmt.Fprintf(g.out, escaped, g.score()) + return nil + + case g.slain(): + fmt.Fprintf(g.out, slain, g.score()) + return nil + } + + select { + case <-ctx.Done(): + return nil + + case err := <-g.errs: + return err + + case <-g.ticker.C: + g.moveMinotaur() + g.render() + + case event := <-g.events: + switch event.Rune { + case 'q': // Quit + return nil + + case 's': // Toggle the solution. + if g.solution == nil { + g.solve() + } else { + g.solution = nil + } + g.render() + continue + } + switch event.Key { + case input.Up: + g.moveTheseus(direction.Up) + case input.Left: + g.moveTheseus(direction.Left) + case input.Down: + g.moveTheseus(direction.Down) + case input.Right: + g.moveTheseus(direction.Right) + } + // If the solution is set, update it. + if g.solution != nil { + g.solve() + } + + g.render() + } + } +} + +func (g *Game) render() { + var params = maze.RenderParameters{ + Positions: map[position.Position]string{}, + Passage: passage, + Wall: wall, + } + for _, p := range g.solution { + params.Positions[p] = solution + } + params.Positions[g.end] = end + params.Positions[g.theseus] = theseus + params.Positions[g.minotaur] = minotaur + + g.out.Write(g.maze.Render(params)) +} + +func (g *Game) score() int { + return int(time.Since(g.start).Round(time.Second).Seconds()) +} + +func (g *Game) quit() { + g.ticker.Stop() + + g.out.Write([]byte(terminal.CursorShow)) + + if err := g.terminal.Reset(); err != nil { + g.log.Error( + "failed to reset terminal attributes", + slog.Any("error", err), + ) + } +} + +func (g *Game) escaped() bool { + return g.theseus == g.end +} + +func (g *Game) moveMinotaur() { + path := g.maze.Solve(g.minotaur, g.theseus) + if len(path) < 2 { + return + } + + g.minotaur = path[1] +} + +func (g *Game) moveTheseus(d direction.Direction) { + if cell := g.maze.Cell(g.theseus); cell.HasDirection(d) { + g.theseus = g.theseus.WithDirection(d) + } +} + +func (g *Game) slain() bool { + return g.minotaur == g.theseus +} + +func (g *Game) solve() { + g.solution = g.maze.Solve(g.theseus, g.end) +}