mumble_game 1.0.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.
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout"
4
+ require_relative "cursor"
5
+ require_relative "colors"
6
+
7
+ module Mumble
8
+ # Hangman ASCII art and rendering with size variants
9
+ module Hangman
10
+ # Small hangman for smaller terminals (17 lines tall)
11
+ STAGES_SMALL = [
12
+ # Stage 0: Empty gallows with rope
13
+ [
14
+ " ╔═══════════╗ ",
15
+ " ║ ║ ",
16
+ " ║ ┃ ",
17
+ " ║ ┃ ",
18
+ " ║ ┃ ",
19
+ " ║ ",
20
+ " ║ ",
21
+ " ║ ",
22
+ " ║ ",
23
+ " ║ ",
24
+ " ║ ",
25
+ " ║ ",
26
+ " ║ ",
27
+ " ║ ",
28
+ " ║ ",
29
+ " ║ ",
30
+ "════╩════════════════"
31
+ ],
32
+ # Stage 1: Head
33
+ [
34
+ " ╔═══════════╗ ",
35
+ " ║ ║ ",
36
+ " ║ ┃ ",
37
+ " ║ ╭┸╮ ",
38
+ " ║ │ │ ",
39
+ " ║ ╰─╯ ",
40
+ " ║ ",
41
+ " ║ ",
42
+ " ║ ",
43
+ " ║ ",
44
+ " ║ ",
45
+ " ║ ",
46
+ " ║ ",
47
+ " ║ ",
48
+ " ║ ",
49
+ " ║ ",
50
+ "════╩════════════════"
51
+ ],
52
+ # Stage 2: Body
53
+ [
54
+ " ╔═══════════╗ ",
55
+ " ║ ║ ",
56
+ " ║ ┃ ",
57
+ " ║ ╭┸╮ ",
58
+ " ║ │ │ ",
59
+ " ║ ╰─╯ ",
60
+ " ║ │ ",
61
+ " ║ │ ",
62
+ " ║ │ ",
63
+ " ║ │ ",
64
+ " ║ │ ",
65
+ " ║ ",
66
+ " ║ ",
67
+ " ║ ",
68
+ " ║ ",
69
+ " ║ ",
70
+ "════╩════════════════"
71
+ ],
72
+ # Stage 3: Left arm
73
+ [
74
+ " ╔═══════════╗ ",
75
+ " ║ ║ ",
76
+ " ║ ┃ ",
77
+ " ║ ╭┸╮ ",
78
+ " ║ │ │ ",
79
+ " ║ ╰─╯ ",
80
+ " ║ │ ",
81
+ " ║ /│ ",
82
+ " ║ / │ ",
83
+ " ║ │ ",
84
+ " ║ │ ",
85
+ " ║ ",
86
+ " ║ ",
87
+ " ║ ",
88
+ " ║ ",
89
+ " ║ ",
90
+ "════╩════════════════"
91
+ ],
92
+ # Stage 4: Right arm
93
+ [
94
+ " ╔═══════════╗ ",
95
+ " ║ ║ ",
96
+ " ║ ┃ ",
97
+ " ║ ╭┸╮ ",
98
+ " ║ │ │ ",
99
+ " ║ ╰─╯ ",
100
+ " ║ │ ",
101
+ " ║ /│\\ ",
102
+ " ║ / │ \\ ",
103
+ " ║ │ ",
104
+ " ║ │ ",
105
+ " ║ ",
106
+ " ║ ",
107
+ " ║ ",
108
+ " ║ ",
109
+ " ║ ",
110
+ "════╩════════════════"
111
+ ],
112
+ # Stage 5: Left leg
113
+ [
114
+ " ╔═══════════╗ ",
115
+ " ║ ║ ",
116
+ " ║ ┃ ",
117
+ " ║ ╭┸╮ ",
118
+ " ║ │ │ ",
119
+ " ║ ╰─╯ ",
120
+ " ║ │ ",
121
+ " ║ /│\\ ",
122
+ " ║ / │ \\ ",
123
+ " ║ │ ",
124
+ " ║ │ ",
125
+ " ║ / ",
126
+ " ║ / ",
127
+ " ║ / ",
128
+ " ║ ",
129
+ " ║ ",
130
+ "════╩════════════════"
131
+ ],
132
+ # Stage 6: Right leg (DEAD)
133
+ [
134
+ " ╔═══════════╗ ",
135
+ " ║ ║ ",
136
+ " ║ ┃ ",
137
+ " ║ ╭┸╮ ",
138
+ " ║ │X│ ",
139
+ " ║ ╰─╯ ",
140
+ " ║ │ ",
141
+ " ║ /│\\ ",
142
+ " ║ / │ \\ ",
143
+ " ║ │ ",
144
+ " ║ │ ",
145
+ " ║ / \\ ",
146
+ " ║ / \\ ",
147
+ " ║ / \\ ",
148
+ " ║ ",
149
+ " ║ ",
150
+ "════╩════════════════"
151
+ ]
152
+ ].freeze
153
+
154
+ # Large hangman for bigger terminals (29 lines tall)
155
+ STAGES_LARGE = [
156
+ # Stage 0: Empty gallows with rope
157
+ [
158
+ " ╔════════════════════╗ ",
159
+ " ║ ║ ",
160
+ " ║ ┃ ",
161
+ " ║ ┃ ",
162
+ " ║ ┃ ",
163
+ " ║ ┃ ",
164
+ " ║ ",
165
+ " ║ ",
166
+ " ║ ",
167
+ " ║ ",
168
+ " ║ ",
169
+ " ║ ",
170
+ " ║ ",
171
+ " ║ ",
172
+ " ║ ",
173
+ " ║ ",
174
+ " ║ ",
175
+ " ║ ",
176
+ " ║ ",
177
+ " ║ ",
178
+ " ║ ",
179
+ " ║ ",
180
+ " ║ ",
181
+ " ║ ",
182
+ " ║ ",
183
+ " ║ ",
184
+ " ║ ",
185
+ " ║ ",
186
+ "══════╩═══════════════════════════"
187
+ ],
188
+ # Stage 1: Head
189
+ [
190
+ " ╔════════════════════╗ ",
191
+ " ║ ║ ",
192
+ " ║ ┃ ",
193
+ " ║ ┃ ",
194
+ " ║ ╭─┸─╮ ",
195
+ " ║ │ │ ",
196
+ " ║ │ │ ",
197
+ " ║ ╰───╯ ",
198
+ " ║ ",
199
+ " ║ ",
200
+ " ║ ",
201
+ " ║ ",
202
+ " ║ ",
203
+ " ║ ",
204
+ " ║ ",
205
+ " ║ ",
206
+ " ║ ",
207
+ " ║ ",
208
+ " ║ ",
209
+ " ║ ",
210
+ " ║ ",
211
+ " ║ ",
212
+ " ║ ",
213
+ " ║ ",
214
+ " ║ ",
215
+ " ║ ",
216
+ " ║ ",
217
+ " ║ ",
218
+ "══════╩═══════════════════════════"
219
+ ],
220
+ # Stage 2: Body
221
+ [
222
+ " ╔════════════════════╗ ",
223
+ " ║ ║ ",
224
+ " ║ ┃ ",
225
+ " ║ ┃ ",
226
+ " ║ ╭─┸─╮ ",
227
+ " ║ │ │ ",
228
+ " ║ │ │ ",
229
+ " ║ ╰───╯ ",
230
+ " ║ │ ",
231
+ " ║ │ ",
232
+ " ║ │ ",
233
+ " ║ │ ",
234
+ " ║ │ ",
235
+ " ║ │ ",
236
+ " ║ │ ",
237
+ " ║ │ ",
238
+ " ║ ",
239
+ " ║ ",
240
+ " ║ ",
241
+ " ║ ",
242
+ " ║ ",
243
+ " ║ ",
244
+ " ║ ",
245
+ " ║ ",
246
+ " ║ ",
247
+ " ║ ",
248
+ " ║ ",
249
+ " ║ ",
250
+ "══════╩═══════════════════════════"
251
+ ],
252
+ # Stage 3: Left arm
253
+ [
254
+ " ╔════════════════════╗ ",
255
+ " ║ ║ ",
256
+ " ║ ┃ ",
257
+ " ║ ┃ ",
258
+ " ║ ╭─┸─╮ ",
259
+ " ║ │ │ ",
260
+ " ║ │ │ ",
261
+ " ║ ╰───╯ ",
262
+ " ║ │ ",
263
+ " ║ /│ ",
264
+ " ║ / │ ",
265
+ " ║ / │ ",
266
+ " ║ / │ ",
267
+ " ║ │ ",
268
+ " ║ │ ",
269
+ " ║ │ ",
270
+ " ║ ",
271
+ " ║ ",
272
+ " ║ ",
273
+ " ║ ",
274
+ " ║ ",
275
+ " ║ ",
276
+ " ║ ",
277
+ " ║ ",
278
+ " ║ ",
279
+ " ║ ",
280
+ " ║ ",
281
+ " ║ ",
282
+ "══════╩═══════════════════════════"
283
+ ],
284
+ # Stage 4: Right arm
285
+ [
286
+ " ╔════════════════════╗ ",
287
+ " ║ ║ ",
288
+ " ║ ┃ ",
289
+ " ║ ┃ ",
290
+ " ║ ╭─┸─╮ ",
291
+ " ║ │ │ ",
292
+ " ║ │ │ ",
293
+ " ║ ╰───╯ ",
294
+ " ║ │ ",
295
+ " ║ /│\\ ",
296
+ " ║ / │ \\ ",
297
+ " ║ / │ \\ ",
298
+ " ║ / │ \\ ",
299
+ " ║ │ ",
300
+ " ║ │ ",
301
+ " ║ │ ",
302
+ " ║ ",
303
+ " ║ ",
304
+ " ║ ",
305
+ " ║ ",
306
+ " ║ ",
307
+ " ║ ",
308
+ " ║ ",
309
+ " ║ ",
310
+ " ║ ",
311
+ " ║ ",
312
+ " ║ ",
313
+ " ║ ",
314
+ "══════╩═══════════════════════════"
315
+ ],
316
+ # Stage 5: Left leg
317
+ [
318
+ " ╔════════════════════╗ ",
319
+ " ║ ║ ",
320
+ " ║ ┃ ",
321
+ " ║ ┃ ",
322
+ " ║ ╭─┸─╮ ",
323
+ " ║ │ │ ",
324
+ " ║ │ │ ",
325
+ " ║ ╰───╯ ",
326
+ " ║ │ ",
327
+ " ║ /│\\ ",
328
+ " ║ / │ \\ ",
329
+ " ║ / │ \\ ",
330
+ " ║ / │ \\ ",
331
+ " ║ │ ",
332
+ " ║ │ ",
333
+ " ║ │ ",
334
+ " ║ / ",
335
+ " ║ / ",
336
+ " ║ / ",
337
+ " ║ / ",
338
+ " ║ / ",
339
+ " ║ ",
340
+ " ║ ",
341
+ " ║ ",
342
+ " ║ ",
343
+ " ║ ",
344
+ " ║ ",
345
+ " ║ ",
346
+ "══════╩═══════════════════════════"
347
+ ],
348
+ # Stage 6: Right leg (DEAD)
349
+ [
350
+ " ╔════════════════════╗ ",
351
+ " ║ ║ ",
352
+ " ║ ┃ ",
353
+ " ║ ┃ ",
354
+ " ║ ╭─┸─╮ ",
355
+ " ║ │ X X │ ",
356
+ " ║ │ │ ",
357
+ " ║ ╰───╯ ",
358
+ " ║ │ ",
359
+ " ║ /│\\ ",
360
+ " ║ / │ \\ ",
361
+ " ║ / │ \\ ",
362
+ " ║ / │ \\ ",
363
+ " ║ │ ",
364
+ " ║ │ ",
365
+ " ║ │ ",
366
+ " ║ / \\ ",
367
+ " ║ / \\ ",
368
+ " ║ / \\ ",
369
+ " ║ / \\ ",
370
+ " ║ / \\ ",
371
+ " ║ ",
372
+ " ║ ",
373
+ " ║ ",
374
+ " ║ ",
375
+ " ║ ",
376
+ " ║ ",
377
+ " ║ ",
378
+ "══════╩═══════════════════════════"
379
+ ]
380
+ ].freeze
381
+
382
+ class << self
383
+ # Get the appropriate stages based on terminal size
384
+ def stages
385
+ case Layout.size_category
386
+ when :large
387
+ STAGES_LARGE
388
+ else
389
+ STAGES_SMALL
390
+ end
391
+ end
392
+
393
+ # Draw hangman at specified position
394
+ # stage: 0-6 (0 = empty, 6 = complete/dead)
395
+ def draw(row:, col:, stage:, color: nil)
396
+ stage = stage.clamp(0, 6)
397
+ lines = stages[stage]
398
+
399
+ lines.each_with_index do |line, index|
400
+ Cursor.move_to(row + index, col)
401
+ if color
402
+ print Colors.send(color, line)
403
+ else
404
+ # Color the body parts based on stage
405
+ print colorize_line(line, stage)
406
+ end
407
+ end
408
+ end
409
+
410
+ # Get the height of the hangman art (dynamic based on size)
411
+ def height
412
+ stages.first.length
413
+ end
414
+
415
+ # Get the width of the hangman art (dynamic based on size)
416
+ def width
417
+ stages.first.map(&:length).max
418
+ end
419
+
420
+ private
421
+
422
+ # Apply color based on how close to death
423
+ # Progression: dim -> yellow -> orange -> red
424
+ def colorize_line(line, stage)
425
+ case stage
426
+ when 0
427
+ Colors.dim(line)
428
+ when 1, 2
429
+ Colors.yellow(line)
430
+ when 3, 4
431
+ Colors.orange(line)
432
+ when 5, 6
433
+ Colors.red(line)
434
+ else
435
+ line
436
+ end
437
+ end
438
+ end
439
+ end
440
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storage"
4
+
5
+ module Mumble
6
+ # Manages the top 10 high scores leaderboard
7
+ module HighScores
8
+ FILENAME = "high_scores.json"
9
+ MAX_ENTRIES = 10
10
+
11
+ class << self
12
+ # Load high scores from file
13
+ def load
14
+ data = Storage.read_json(FILENAME)
15
+ return [] if data.nil?
16
+
17
+ data[:scores] || []
18
+ end
19
+
20
+ # Alias for load - clearer API
21
+ def all
22
+ load
23
+ end
24
+
25
+ # Check if there are any scores
26
+ def any?
27
+ all.any?
28
+ end
29
+
30
+ # Save high scores to file
31
+ def save(scores)
32
+ Storage.write_json(FILENAME, { scores: scores })
33
+ end
34
+
35
+ # Check if a score qualifies for the leaderboard
36
+ def qualifies?(score)
37
+ scores = load
38
+ return true if scores.length < MAX_ENTRIES
39
+
40
+ score > scores.last[:score]
41
+ end
42
+
43
+ # Add a new score to the leaderboard
44
+ # Returns the position (1-10) if added, nil if didn't qualify
45
+ def add(name:, score:, level:)
46
+ return nil unless qualifies?(score)
47
+
48
+ scores = load
49
+
50
+ new_entry = {
51
+ name: name.to_s.strip[0, 20],
52
+ score: score,
53
+ level: level,
54
+ date: Time.now.strftime("%b %d, %Y")
55
+ }
56
+
57
+ scores << new_entry
58
+ scores = scores.sort_by { |s| -s[:score] }
59
+ scores = scores.first(MAX_ENTRIES)
60
+
61
+ save(scores)
62
+
63
+ # Return position (1-indexed)
64
+ scores.index { |s| s[:score] == score && s[:name] == new_entry[:name] } + 1
65
+ end
66
+
67
+ # Get a specific rank's score (for display)
68
+ def score_at(rank)
69
+ scores = load
70
+ return nil if rank < 1 || rank > scores.length
71
+
72
+ scores[rank - 1]
73
+ end
74
+
75
+ # Get the player's best score
76
+ def best_score_for(name)
77
+ scores = load
78
+ player_scores = scores.select { |s| s[:name] == name }
79
+ return nil if player_scores.empty?
80
+
81
+ player_scores.first
82
+ end
83
+
84
+ # Get the player's best rank
85
+ def best_rank_for(name)
86
+ scores = load
87
+ index = scores.index { |s| s[:name] == name }
88
+ return nil if index.nil?
89
+
90
+ index + 1
91
+ end
92
+
93
+ # Clear all high scores
94
+ def clear
95
+ Storage.delete_file(FILENAME)
96
+ end
97
+
98
+ # Check if leaderboard is empty
99
+ def empty?
100
+ load.empty?
101
+ end
102
+
103
+ # Get count of entries
104
+ def count
105
+ load.length
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-reader"
4
+
5
+ module Mumble
6
+ # Handles keyboard input for menus and gameplay
7
+ module Input
8
+ # Key constants for easier reference
9
+ KEYS = {
10
+ enter: :return,
11
+ escape: :escape,
12
+ up: :up,
13
+ down: :down,
14
+ left: :left,
15
+ right: :right,
16
+ backspace: :backspace,
17
+ delete: :delete
18
+ }.freeze
19
+
20
+ class << self
21
+ # Memoized reader instance
22
+ def reader
23
+ @reader ||= TTY::Reader.new(interrupt: :exit)
24
+ end
25
+
26
+ # Read a single keypress and return a symbol
27
+ # Returns: :up, :down, :left, :right, :enter, :escape, :backspace, or the character
28
+ def read_key
29
+ key = reader.read_keypress
30
+
31
+ case key
32
+ when "\r", "\n" then :enter
33
+ when "\e" then :escape
34
+ when "\u007F", "\b" then :backspace
35
+ else
36
+ key
37
+ end
38
+ end
39
+
40
+ # Read a single keypress with full key event info
41
+ # Useful for arrow keys and special keys
42
+ def read_key_event
43
+ reader.read_keypress
44
+ end
45
+
46
+ # Read a line of text input
47
+ # prompt: optional prompt to display
48
+ # default: default value if user presses Enter without typing
49
+ def read_line(prompt: "", default: "")
50
+ reader.read_line(prompt, value: default).chomp
51
+ end
52
+
53
+ # Read input character by character with a block
54
+ # Useful for real-time input validation (like limiting to 5 letters)
55
+ # max_length: maximum characters allowed
56
+ # allowed_chars: regex pattern for allowed characters (default: letters only)
57
+ def read_chars(max_length:, allowed_chars: /[a-zA-Z]/)
58
+ buffer = ""
59
+
60
+ loop do
61
+ event = reader.read_keypress
62
+
63
+ case event
64
+ when "\r", "\n"
65
+ break if buffer.length.positive?
66
+ when "\u007F", "\b"
67
+ # Backspace - remove last character
68
+ unless buffer.empty?
69
+ buffer = buffer[0...-1]
70
+ yield(:backspace, buffer) if block_given?
71
+ end
72
+ when "\e"
73
+ yield(:escape, buffer) if block_given?
74
+ return nil
75
+ else
76
+ # Regular character - add if allowed and under max length
77
+ if event.match?(allowed_chars) && buffer.length < max_length
78
+ buffer += event.upcase
79
+ yield(:char, buffer) if block_given?
80
+ end
81
+ end
82
+ end
83
+
84
+ buffer
85
+ end
86
+
87
+ # Wait for user to press Enter
88
+ def wait_for_enter
89
+ loop do
90
+ key = read_key
91
+ break if key == :enter
92
+ end
93
+ end
94
+
95
+ # Wait for user to press any key
96
+ def wait_for_any_key
97
+ reader.read_keypress
98
+ end
99
+
100
+ # Read a yes/no response
101
+ # Returns true for yes, false for no
102
+ def read_yes_no
103
+ loop do
104
+ key = read_key.to_s.downcase
105
+ return true if %w[y yes].include?(key) || key == :enter
106
+ return false if %w[n no].include?(key)
107
+ end
108
+ end
109
+
110
+ # Read menu selection with arrow keys
111
+ # options_count: number of menu options
112
+ # initial: starting selection (0-indexed)
113
+ # Returns selected index when Enter is pressed, or nil if Escape
114
+ def read_menu_selection(options_count:, initial: 0)
115
+ current = initial
116
+
117
+ loop do
118
+ event = reader.read_keypress
119
+
120
+ case event
121
+ when "\e[A", "k" # Up arrow or k
122
+ current = (current - 1) % options_count
123
+ yield(current) if block_given?
124
+ when "\e[B", "j" # Down arrow or j
125
+ current = (current + 1) % options_count
126
+ yield(current) if block_given?
127
+ when "\r", "\n" # Enter
128
+ return current
129
+ when "\e" # Escape (standalone, not part of arrow sequence)
130
+ return nil
131
+ when "1".."9" # Number keys for direct selection
132
+ num = event.to_i - 1
133
+ if num < options_count
134
+ current = num
135
+ yield(current) if block_given?
136
+ return current
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end