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 }