minehunter 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/exe/minehunt ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ load File.expand_path("minehunter", __dir__)
data/exe/minehunter ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/minehunter"
4
+
5
+ Minehunter.run
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-option"
5
+ require "tty-screen"
6
+
7
+ require_relative "game"
8
+ require_relative "version"
9
+
10
+ module Minehunter
11
+ # The main interface to the game
12
+ #
13
+ # @api public
14
+ class CLI
15
+ include TTY::Option
16
+
17
+ LEVELS = {
18
+ "easy" => {width: 9, height: 9, mines: 10},
19
+ "medium" => {width: 16, height: 16, mines: 40},
20
+ "hard" => {width: 30, height: 16, mines: 99}
21
+ }.freeze
22
+
23
+ usage do
24
+ no_command
25
+
26
+ desc "Hunt down all the mines and uncover remaining fields"
27
+
28
+ example "To play the game on a 20x15 grid with 35 mines run"
29
+
30
+ example "$ #{program} -c 20 -r 15 -m 35"
31
+ end
32
+
33
+ option :width do
34
+ short "-c"
35
+ long "--cols INT"
36
+ desc "Set number of columns"
37
+ convert :int
38
+ end
39
+
40
+ option :height do
41
+ short "-r"
42
+ long "--rows INT"
43
+ desc "Set number of rows"
44
+ convert :int
45
+ end
46
+
47
+ option :level do
48
+ default "medium"
49
+ short "-l"
50
+ long "--level NAME"
51
+ desc "Set difficulty level"
52
+ permit %w[easy medium hard]
53
+ end
54
+
55
+ option :mines do
56
+ short "-m"
57
+ long "--mines INT"
58
+ desc "Set number of mines"
59
+ convert :int
60
+ end
61
+
62
+ flag :help do
63
+ short "-h"
64
+ long "--help"
65
+ desc "Print usage"
66
+ end
67
+
68
+ flag :version do
69
+ short "-v"
70
+ long "--version"
71
+ desc "Print version"
72
+ end
73
+
74
+ # Run the game
75
+ #
76
+ # @param [Array<String>] argv
77
+ # the command line parameters
78
+ # @param [IO] input
79
+ # the input stream, defaults to stdin
80
+ # @param [IO] output
81
+ # the output stream, defaults to stdout
82
+ # @param [Hash] env
83
+ # the environment variables
84
+ # @param [Boolean] color
85
+ # whether or not to style the game
86
+ # @param [Integer] screen_width
87
+ # the terminal screen width
88
+ # @param [Integer] screen_height
89
+ # the terminal screen height
90
+ #
91
+ # @api public
92
+ def run(argv = ARGV, input: $stdin, output: $stdout, env: {}, color: nil,
93
+ screen_width: TTY::Screen.width, screen_height: TTY::Screen.height)
94
+ parse(argv)
95
+
96
+ if params[:help]
97
+ output.print help
98
+ exit
99
+ elsif params[:version]
100
+ output.puts VERSION
101
+ exit
102
+ elsif params.errors.any?
103
+ output.puts params.errors.summary
104
+ exit 1
105
+ else
106
+ level = LEVELS[params[:level]]
107
+ decorator = Pastel.new(enabled: color).method(:decorate)
108
+ game = Game.new(
109
+ input: input,
110
+ output: output,
111
+ env: env,
112
+ width: params[:width] || level[:width],
113
+ height: params[:height] || level[:height],
114
+ screen_width: screen_width,
115
+ screen_height: screen_height,
116
+ mines_limit: params[:mines] || level[:mines],
117
+ decorator: decorator
118
+ )
119
+ game.run
120
+ end
121
+ rescue Minehunter::Error => err
122
+ output.puts "Error: #{err}"
123
+ exit 1
124
+ end
125
+ end # CLI
126
+ end # Minehunter
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minehunter
4
+ # A field on a gird representation
5
+ #
6
+ # @api private
7
+ class Field
8
+ BOMB = "*"
9
+ COVER = "░"
10
+ EMPTY = " "
11
+ FLAG = "F"
12
+ WRONG = "X"
13
+
14
+ # Mappings of mine counts to colour names
15
+ MINE_COUNT_TO_COLOR = {
16
+ 1 => :cyan,
17
+ 2 => :green,
18
+ 3 => :red,
19
+ 4 => :blue,
20
+ 5 => :magenta,
21
+ 6 => :yellow,
22
+ 7 => :bright_cyan,
23
+ 8 => :bright_green
24
+ }.freeze
25
+
26
+ # The number of mines in nearby fields
27
+ #
28
+ # @api public
29
+ attr_accessor :mine_count
30
+
31
+ # Create a Field instance
32
+ #
33
+ # @api public
34
+ def initialize
35
+ @flag = false
36
+ @mine = false
37
+ @cover = true
38
+ @wrong = false
39
+ @mine_count = 0
40
+ end
41
+
42
+ # Toggle flag for a covered field
43
+ #
44
+ # @api public
45
+ def flag
46
+ return unless cover?
47
+
48
+ @flag = !@flag
49
+ end
50
+
51
+ # Whether or not there is a flag placed
52
+ #
53
+ # @return [Boolean]
54
+ #
55
+ # @api public
56
+ def flag?
57
+ @flag
58
+ end
59
+
60
+ # Mark as having a mine
61
+ #
62
+ # @api public
63
+ def mine!
64
+ @mine = true
65
+ end
66
+
67
+ # Whether or not the field has mine
68
+ #
69
+ # @return [Boolean]
70
+ #
71
+ # @api public
72
+ def mine?
73
+ @mine
74
+ end
75
+
76
+ # Uncover this field
77
+ #
78
+ # @api public
79
+ def uncover
80
+ @cover = false
81
+ end
82
+
83
+ # Whether or not the field has cover
84
+ #
85
+ # @return [Boolean]
86
+ #
87
+ # @api public
88
+ def cover?
89
+ @cover
90
+ end
91
+
92
+ # Mark as having wrongly placed flag
93
+ #
94
+ # @api public
95
+ def wrong
96
+ @wrong = true
97
+ end
98
+
99
+ # Whether or not a flag is placed wrongly
100
+ #
101
+ # @return [Boolean]
102
+ #
103
+ # @api public
104
+ def wrong?
105
+ @wrong
106
+ end
107
+
108
+ # Render the field
109
+ #
110
+ # @param [Proc] decorator
111
+ # apply style formatting
112
+ #
113
+ # @return [String]
114
+ #
115
+ # @api public
116
+ def render(decorator: DEFAULT_DECORATOR)
117
+ if !cover?
118
+ if mine? then BOMB
119
+ elsif flag? && wrong? then decorator[WRONG, :on_red]
120
+ elsif !mine_count.zero?
121
+ decorator[mine_count.to_s, MINE_COUNT_TO_COLOR[mine_count]]
122
+ else EMPTY end
123
+ elsif flag? then FLAG
124
+ else COVER end
125
+ end
126
+ end # Field
127
+ end # Minehunter
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-box"
4
+ require "tty-cursor"
5
+ require "tty-reader"
6
+
7
+ require_relative "grid"
8
+ require_relative "intro"
9
+
10
+ module Minehunter
11
+ # Responsible for playing mine hunting game
12
+ #
13
+ # @api public
14
+ class Game
15
+ # The keys to exit game
16
+ #
17
+ # @api private
18
+ EXIT_KEYS = [?\C-x, "q", "\e"].freeze
19
+
20
+ # The terminal cursor clearing and positioning
21
+ #
22
+ # @api public
23
+ attr_reader :cursor
24
+
25
+ # Create a Game instance
26
+ #
27
+ # @param [IO] input
28
+ # the input stream, defaults to stdin
29
+ # @param [IO] output
30
+ # the output stream, defaults to stdout
31
+ # @param [Hash] env
32
+ # the environment variables
33
+ # @param [Integer] width
34
+ # the number of columns
35
+ # @param [Integer] height
36
+ # the number of rows
37
+ # @param [Integer] mines_limit
38
+ # the total number of mines
39
+ # @param [Integer] screen_width
40
+ # the terminal screen width
41
+ # @param [Integer] screen_height
42
+ # the terminal screen height
43
+ # @param [Pastel] decorator
44
+ # the decorator for styling
45
+ # @param [Proc] randomiser
46
+ # the random number generator
47
+ #
48
+ # @api public
49
+ def initialize(input: $stdin, output: $stdout, env: {},
50
+ width: nil, height: nil, mines_limit: nil,
51
+ screen_width: nil, screen_height: nil,
52
+ decorator: DEFAULT_DECORATOR, randomiser: DEFAULT_RANDOMISER)
53
+ @output = output
54
+ @width = width
55
+ @top = (screen_height - height - 4) / 2
56
+ @left = (screen_width - width - 4) / 2
57
+ @pos_x = (width - 1) / 2
58
+ @pos_y = (height - 1) / 2
59
+ @decorator = decorator
60
+ @randomiser = randomiser
61
+ @box = TTY::Box
62
+ @cursor = TTY::Cursor
63
+ @reader = TTY::Reader.new(input: input, output: output, env: env,
64
+ interrupt: :exit)
65
+ @grid = Grid.new(width: width, height: height, mines_limit: mines_limit)
66
+ @intro = Intro
67
+ @intro_top = (screen_height - @intro.height - 2) / 2
68
+ @intro_left = (screen_width - @intro.width - 4) / 2
69
+
70
+ reset
71
+ end
72
+
73
+ # Reset game
74
+ #
75
+ # @api public
76
+ def reset
77
+ @curr_x = @pos_x
78
+ @curr_y = @pos_y
79
+ @first_uncover = true
80
+ @lost = false
81
+ @stop = false
82
+ @grid.reset
83
+ end
84
+
85
+ # Check whether or not the game is finished
86
+ #
87
+ # @return [Boolean]
88
+ #
89
+ # @api public
90
+ def finished?
91
+ @lost || @grid.cleared?
92
+ end
93
+
94
+ # Start the game
95
+ #
96
+ # @api public
97
+ def run
98
+ @output.print cursor.hide + cursor.clear_screen + render_intro_box
99
+ pressed_key = @reader.read_keypress
100
+ keyctrl_x if EXIT_KEYS.include?(pressed_key)
101
+
102
+ @output.print cursor.clear_screen
103
+ @reader.subscribe(self)
104
+
105
+ until @stop
106
+ @output.print render_status_box + render_grid_box
107
+ @reader.read_keypress
108
+ end
109
+ ensure
110
+ @output.print cursor.show
111
+ end
112
+
113
+ # Render box with intro
114
+ #
115
+ # @return [String]
116
+ #
117
+ # @api private
118
+ def render_intro_box
119
+ @box.frame(
120
+ @intro.render,
121
+ top: @intro_top,
122
+ left: @intro_left,
123
+ padding: [0, 1]
124
+ )
125
+ end
126
+
127
+ # Render box with status message
128
+ #
129
+ # @return [String]
130
+ #
131
+ # @api private
132
+ def render_status_box
133
+ @box.frame(
134
+ status,
135
+ top: @top,
136
+ left: @left,
137
+ width: @width + 4,
138
+ padding: [0, 1],
139
+ border: {bottom: false},
140
+ align: :center
141
+ )
142
+ end
143
+
144
+ # Render box with grid
145
+ #
146
+ # @return [String]
147
+ #
148
+ # @api private
149
+ def render_grid_box
150
+ @box.frame(
151
+ render_grid,
152
+ top: @top + 2,
153
+ left: @left,
154
+ padding: [0, 1],
155
+ border: {
156
+ top_left: :divider_right,
157
+ top_right: :divider_left
158
+ }
159
+ )
160
+ end
161
+
162
+ # Status message
163
+ #
164
+ # @return [String]
165
+ #
166
+ # @api public
167
+ def status
168
+ if @lost
169
+ "GAME OVER"
170
+ elsif @grid.cleared?
171
+ "YOU WIN"
172
+ else
173
+ "Flags #{@grid.flags_remaining}"
174
+ end
175
+ end
176
+
177
+ # Render grid with current position marker
178
+ #
179
+ # @api private
180
+ def render_grid
181
+ @grid.render(@curr_x, @curr_y, decorator: @decorator)
182
+ end
183
+
184
+ # Control game movement and actions
185
+ #
186
+ # @param [TTY::Reader::KeyEvent] event
187
+ # the keypress event
188
+ #
189
+ # @api private
190
+ def keypress(event)
191
+ case event.value.to_sym
192
+ when :h, :a then keyleft
193
+ when :l, :d then keyright
194
+ when :j, :s then keydown
195
+ when :k, :w then keyup
196
+ when :f, :g then flag
197
+ when :r then reset
198
+ when :q then keyctrl_x
199
+ end
200
+ end
201
+
202
+ # Place a flag
203
+ #
204
+ # @api private
205
+ def flag
206
+ return if finished?
207
+
208
+ @grid.flag(@curr_x, @curr_y) unless finished?
209
+ end
210
+
211
+ # Quit game
212
+ #
213
+ # @api private
214
+ def keyctrl_x(*)
215
+ @output.print cursor.clear_screen
216
+ @output.print cursor.move_to(0, 0)
217
+ @stop = true
218
+ end
219
+ alias keyescape keyctrl_x
220
+
221
+ # Uncover a field
222
+ #
223
+ # @api private
224
+ def keyspace(*)
225
+ return if @grid.flag?(@curr_x, @curr_y)
226
+
227
+ if @first_uncover
228
+ @grid.fill_with_mines(@curr_x, @curr_y, randomiser: @randomiser)
229
+ @first_uncover = false
230
+ end
231
+ @lost = @grid.uncover(@curr_x, @curr_y)
232
+ end
233
+ alias keyenter keyspace
234
+ alias keyreturn keyspace
235
+
236
+ # Move cursor up
237
+ #
238
+ # @api private
239
+ def keyup(*)
240
+ return if finished?
241
+
242
+ @curr_y = @grid.move_up(@curr_y)
243
+ end
244
+
245
+ # Move cursor down
246
+ #
247
+ # @api private
248
+ def keydown(*)
249
+ return if finished?
250
+
251
+ @curr_y = @grid.move_down(@curr_y)
252
+ end
253
+
254
+ # Move cursor left
255
+ #
256
+ # @api private
257
+ def keyleft(*)
258
+ return if finished?
259
+
260
+ @curr_x = @grid.move_left(@curr_x)
261
+ end
262
+
263
+ # Move cursor right
264
+ #
265
+ # @api private
266
+ def keyright(*)
267
+ return if finished?
268
+
269
+ @curr_x = @grid.move_right(@curr_x)
270
+ end
271
+ end # Game
272
+ end # Minehunter