Add minotaur pkg
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user