src

Go monorepo.
git clone git://code.dwrz.net/src
Log | Files | Refs

minotaur.go (5162B)


      1 package minotaur
      2 
      3 import (
      4 	"context"
      5 	"fmt"
      6 	"os"
      7 	"time"
      8 
      9 	"code.dwrz.net/src/pkg/color"
     10 	"code.dwrz.net/src/pkg/log"
     11 	"code.dwrz.net/src/pkg/minotaur/maze"
     12 	"code.dwrz.net/src/pkg/minotaur/maze/direction"
     13 	"code.dwrz.net/src/pkg/minotaur/maze/position"
     14 	"code.dwrz.net/src/pkg/terminal"
     15 	"code.dwrz.net/src/pkg/terminal/input"
     16 )
     17 
     18 const (
     19 	// Game over messages.
     20 	slain   = "You were slain by the Minotaur. Score: %v.\r\n"
     21 	escaped = "You escaped the labyrinth. Score: %v.\r\n"
     22 
     23 	// Blocks for rendering.
     24 	end      = color.BackgroundGreen + "  " + color.Reset
     25 	minotaur = color.BackgroundYellow + "  " + color.Reset
     26 	passage  = "  "
     27 	theseus  = color.BackgroundMagenta + "  " + color.Reset
     28 	solution = color.BackgroundBlue + "  " + color.Reset
     29 	wall     = color.BackgroundBlack + "  " + color.Reset
     30 )
     31 
     32 type Game struct {
     33 	errs     chan error
     34 	events   chan *input.Event
     35 	input    *input.Reader
     36 	log      *log.Logger
     37 	maze     *maze.Maze
     38 	out      *os.File
     39 	start    time.Time
     40 	terminal *terminal.Terminal
     41 	ticker   *time.Ticker
     42 
     43 	// Maze positions.
     44 	end      position.Position
     45 	theseus  position.Position
     46 	minotaur position.Position
     47 	solution []position.Position
     48 }
     49 
     50 type Parameters struct {
     51 	Height   int
     52 	In       *os.File
     53 	Log      *log.Logger
     54 	Out      *os.File
     55 	Terminal *terminal.Terminal
     56 	Tick     time.Duration
     57 	Width    int
     58 }
     59 
     60 func New(p Parameters) (*Game, error) {
     61 	// Account for two spaces per cell; horizontal passages and walls.
     62 	// Account an extra row for the right wall.
     63 	if p.Width%4 == 0 {
     64 		p.Width -= 1
     65 	}
     66 	p.Width /= 4
     67 
     68 	// Account extra rows for vertical passages and walls.
     69 	if p.Height%2 == 0 {
     70 		p.Height -= 1
     71 	}
     72 	p.Height /= 2
     73 
     74 	// If either the terminal width or height are zero,
     75 	// then the terminal is too small to render the maze.
     76 	if p.Height == 0 || p.Width == 0 {
     77 		return nil, fmt.Errorf("terminal too small")
     78 	}
     79 
     80 	var g = &Game{
     81 		errs:     make(chan error),
     82 		events:   make(chan *input.Event),
     83 		log:      p.Log,
     84 		out:      p.Out,
     85 		terminal: p.Terminal,
     86 		ticker:   time.NewTicker(p.Tick),
     87 	}
     88 
     89 	maze, err := maze.New(maze.Parameters{
     90 		Height: p.Height,
     91 		Log:    g.log,
     92 		Width:  p.Width,
     93 	})
     94 	if err != nil {
     95 		return nil, fmt.Errorf("failed to generate maze: %v", err)
     96 	}
     97 	g.maze = maze
     98 
     99 	g.input = input.New(input.Parameters{
    100 		Chan: g.events,
    101 		In:   os.Stdin,
    102 		Log:  g.log,
    103 	})
    104 
    105 	// Set start and end.
    106 	g.theseus = position.Random(p.Width, p.Height)
    107 	g.end = position.Random(p.Width, p.Height)
    108 	g.minotaur = position.Random(p.Width, p.Height)
    109 
    110 	return g, nil
    111 }
    112 
    113 func (g *Game) Run(ctx context.Context) error {
    114 	// Setup the terminal.
    115 	if err := g.terminal.SetRaw(); err != nil {
    116 		return fmt.Errorf(
    117 			"failed to set terminal attributes: %v", err,
    118 		)
    119 	}
    120 
    121 	// Clean up before exit.
    122 	defer g.quit()
    123 
    124 	// TODO: refactor to use a terminal output package.
    125 	g.out.WriteString(terminal.ClearScreen)
    126 	g.out.WriteString(terminal.CursorHide)
    127 	g.render()
    128 
    129 	// Start reading user input.
    130 	go func() {
    131 		if err := g.input.Run(ctx); err != nil {
    132 			g.errs <- err
    133 		}
    134 	}()
    135 
    136 	// Main loop.
    137 	g.start = time.Now()
    138 	for {
    139 		select {
    140 		case <-ctx.Done():
    141 			return nil
    142 
    143 		case err := <-g.errs:
    144 			return err
    145 
    146 		case <-g.ticker.C:
    147 			g.moveMinotaur()
    148 			g.render()
    149 
    150 		case event := <-g.events:
    151 			switch event.Rune {
    152 			case 'q': // Quit
    153 				return nil
    154 
    155 			case 's': // Toggle the solution.
    156 				if g.solution == nil {
    157 					g.solve()
    158 				} else {
    159 					g.solution = nil
    160 				}
    161 				g.render()
    162 				continue
    163 			}
    164 			switch event.Key {
    165 			case input.Up:
    166 				g.moveTheseus(direction.Up)
    167 			case input.Left:
    168 				g.moveTheseus(direction.Left)
    169 			case input.Down:
    170 				g.moveTheseus(direction.Down)
    171 			case input.Right:
    172 				g.moveTheseus(direction.Right)
    173 			}
    174 			// If the solution is set, update it.
    175 			if g.solution != nil {
    176 				g.solve()
    177 			}
    178 
    179 			g.render()
    180 
    181 		default:
    182 			// Check for game over conditions.
    183 			switch {
    184 			case g.escaped():
    185 				fmt.Fprintf(g.out, escaped, g.score())
    186 				return nil
    187 
    188 			case g.slain():
    189 				fmt.Fprintf(g.out, slain, g.score())
    190 				return nil
    191 			}
    192 		}
    193 	}
    194 }
    195 
    196 func (g *Game) render() {
    197 	var params = maze.RenderParameters{
    198 		Positions: map[position.Position]string{},
    199 		Passage:   passage,
    200 		Wall:      wall,
    201 	}
    202 	for _, p := range g.solution {
    203 		params.Positions[p] = solution
    204 	}
    205 	params.Positions[g.end] = end
    206 	params.Positions[g.theseus] = theseus
    207 	params.Positions[g.minotaur] = minotaur
    208 
    209 	g.out.Write(g.maze.Render(params))
    210 }
    211 
    212 func (g *Game) score() int {
    213 	return int(time.Since(g.start).Round(time.Second).Seconds())
    214 }
    215 
    216 func (g *Game) quit() {
    217 	g.out.Write([]byte(terminal.CursorShow))
    218 
    219 	if err := g.terminal.Reset(); err != nil {
    220 		g.log.Error.Printf(
    221 			"failed to reset terminal attributes: %v", err,
    222 		)
    223 	}
    224 }
    225 
    226 func (g *Game) escaped() bool {
    227 	return g.theseus == g.end
    228 }
    229 
    230 func (g *Game) moveMinotaur() {
    231 	path := g.maze.Solve(g.minotaur, g.theseus)
    232 	if len(path) < 2 {
    233 		return
    234 	}
    235 
    236 	g.minotaur = path[1]
    237 }
    238 
    239 func (g *Game) moveTheseus(d direction.Direction) {
    240 	if cell := g.maze.Cell(g.theseus); cell.HasDirection(d) {
    241 		g.theseus = g.theseus.WithDirection(d)
    242 	}
    243 }
    244 
    245 func (g *Game) slain() bool {
    246 	return g.minotaur == g.theseus
    247 }
    248 
    249 func (g *Game) solve() {
    250 	g.solution = g.maze.Solve(g.theseus, g.end)
    251 }