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