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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +130 -0
- data/bin/mumble +6 -0
- data/lib/mumble/animations.rb +112 -0
- data/lib/mumble/box.rb +139 -0
- data/lib/mumble/colors.rb +83 -0
- data/lib/mumble/config.rb +172 -0
- data/lib/mumble/cursor.rb +82 -0
- data/lib/mumble/error_handler.rb +74 -0
- data/lib/mumble/grid.rb +182 -0
- data/lib/mumble/hangman.rb +440 -0
- data/lib/mumble/high_scores.rb +109 -0
- data/lib/mumble/input.rb +143 -0
- data/lib/mumble/layout.rb +208 -0
- data/lib/mumble/scorer.rb +78 -0
- data/lib/mumble/screen.rb +61 -0
- data/lib/mumble/screens/base.rb +142 -0
- data/lib/mumble/screens/gameplay.rb +433 -0
- data/lib/mumble/screens/high_scores.rb +126 -0
- data/lib/mumble/screens/lose.rb +108 -0
- data/lib/mumble/screens/main_menu.rb +130 -0
- data/lib/mumble/screens/name_input.rb +121 -0
- data/lib/mumble/screens/play_again.rb +97 -0
- data/lib/mumble/screens/profile.rb +154 -0
- data/lib/mumble/screens/quit_confirm.rb +103 -0
- data/lib/mumble/screens/rules.rb +102 -0
- data/lib/mumble/screens/splash.rb +130 -0
- data/lib/mumble/screens/win.rb +139 -0
- data/lib/mumble/storage.rb +85 -0
- data/lib/mumble/version.rb +5 -0
- data/lib/mumble/word_cache.rb +131 -0
- data/lib/mumble/word_service.rb +192 -0
- data/lib/mumble.rb +340 -0
- metadata +137 -0
|
@@ -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
|
data/lib/mumble/input.rb
ADDED
|
@@ -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
|