Add minotaur pkg

This commit is contained in:
dwrz
2026-10-25 02:51:43 +00:00
parent 17d9903f17
commit b82b14dd7c
+261
View File
@@ -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)
}