minehunter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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