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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +555 -0
- data/README.md +101 -0
- data/exe/minehunt +3 -0
- data/exe/minehunter +5 -0
- data/lib/minehunter/cli.rb +126 -0
- data/lib/minehunter/field.rb +127 -0
- data/lib/minehunter/game.rb +272 -0
- data/lib/minehunter/grid.rb +384 -0
- data/lib/minehunter/intro.rb +52 -0
- data/lib/minehunter/version.rb +5 -0
- data/lib/minehunter.rb +23 -0
- metadata +178 -0
data/exe/minehunt
ADDED
data/exe/minehunter
ADDED
@@ -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
|