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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../grid"
5
+ require_relative "../hangman"
6
+
7
+ module Mumble
8
+ module Screens
9
+ # Main gameplay screen - ties together grid, hangman, timer, and input
10
+ class Gameplay < Base
11
+ def initialize(level:, target_word:)
12
+ super()
13
+ @level = level
14
+ @target_word = target_word.upcase
15
+ @guesses = [] # Array of guess results
16
+ @current_input = "" # What user is typing
17
+ @wrong_count = 0 # Hangman stage (0-6)
18
+ @used_letters = {} # letter => :correct, :present, :absent
19
+ @start_time = nil # Timer starts when game begins
20
+ @game_over = false
21
+ @won = false
22
+ @timer_thread = nil
23
+ end
24
+
25
+ def show
26
+ Cursor.hide
27
+ draw_screen
28
+
29
+ # Main game loop
30
+ game_loop until @game_over
31
+
32
+ # Stop background timer
33
+ stop_timer_thread
34
+
35
+ # Show final state and wait for keypress
36
+ draw_screen
37
+ Input.wait_for_any_key
38
+
39
+ # Return result for game loop to handle
40
+ { won: @won, level: @level, guesses: @guesses.length, time: elapsed_time }
41
+ end
42
+
43
+ private
44
+
45
+ # --- MAIN GAME LOOP ---
46
+
47
+ def game_loop
48
+ # Start timer immediately when game begins
49
+ @start_time ||= Time.now
50
+
51
+ # Start background timer thread if not already running
52
+ start_timer_thread unless @timer_thread&.alive?
53
+
54
+ # Read input (blocking)
55
+ char = Input.read_key
56
+
57
+ return unless char
58
+
59
+ handle_input(char)
60
+ end
61
+
62
+ # Background thread to update timer display
63
+ def start_timer_thread
64
+ @timer_thread = Thread.new do
65
+ loop do
66
+ sleep(1)
67
+ break if @game_over
68
+
69
+ update_timer_display
70
+ end
71
+ end
72
+ end
73
+
74
+ # Stop the timer thread
75
+ def stop_timer_thread
76
+ @timer_thread&.kill
77
+ @timer_thread = nil
78
+ end
79
+
80
+ def handle_input(char)
81
+ case char
82
+ when :enter
83
+ submit_guess if @current_input.length == 5
84
+ when :backspace
85
+ handle_backspace
86
+ when :escape
87
+ # Could add pause menu later
88
+ nil
89
+ else
90
+ handle_character(char)
91
+ end
92
+ end
93
+
94
+ def handle_character(char)
95
+ # Only accept letters, max 5 characters
96
+ return unless char.is_a?(String) && char.match?(/\A[a-zA-Z]\z/)
97
+ return if @current_input.length >= 5
98
+
99
+ @current_input += char.upcase
100
+ redraw_current_row
101
+ end
102
+
103
+ def handle_backspace
104
+ return if @current_input.empty?
105
+
106
+ @current_input = @current_input[0...-1]
107
+ redraw_current_row
108
+ end
109
+
110
+ def submit_guess
111
+ return if @current_input.length != 5
112
+
113
+ # Process the guess and get colored result
114
+ result = process_guess(@current_input)
115
+
116
+ # Animate the letter reveal
117
+ animate_letter_reveal(result)
118
+
119
+ # Clear input for next row
120
+ @current_input = ""
121
+
122
+ # Redraw to show results
123
+ draw_screen
124
+ end
125
+
126
+ # Animate each letter revealing with color
127
+ def animate_letter_reveal(result)
128
+ row_index = @guesses.length - 1 # Just added, so -1
129
+ base_row = grid_row + (row_index * (Grid.dimensions[:cell_height] + Grid.dimensions[:v_gap]))
130
+
131
+ result.each_with_index do |letter_result, col_index|
132
+ cell_col = grid_col + (col_index * (Grid.dimensions[:cell_width] + Grid.dimensions[:h_gap]))
133
+
134
+ # Brief flash white before showing color
135
+ Grid.draw_cell(row: base_row, col: cell_col, letter: letter_result[:letter], state: :typing)
136
+ sleep(0.1)
137
+
138
+ # Reveal with actual color
139
+ Grid.draw_cell(row: base_row, col: cell_col, letter: letter_result[:letter], state: letter_result[:state])
140
+ sleep(0.15)
141
+ end
142
+ end
143
+
144
+ # --- LAYOUT CALCULATIONS ---
145
+
146
+ def game_width
147
+ case Layout.size_category
148
+ when :large
149
+ Grid.total_width + spacing_between + Hangman.width + 20
150
+ else
151
+ Grid.total_width + spacing_between + Hangman.width + 10
152
+ end
153
+ end
154
+
155
+ def game_height
156
+ case Layout.size_category
157
+ when :large
158
+ Grid.total_height + 10
159
+ else
160
+ Grid.total_height + 8
161
+ end
162
+ end
163
+
164
+ def game_start_row
165
+ (Screen.height - game_height) / 2
166
+ end
167
+
168
+ def game_start_col
169
+ (Screen.width - game_width) / 2
170
+ end
171
+
172
+ def content_start_row
173
+ game_start_row + 4
174
+ end
175
+
176
+ def grid_col
177
+ case Layout.size_category
178
+ when :large
179
+ game_start_col + 5
180
+ else
181
+ game_start_col + 2
182
+ end
183
+ end
184
+
185
+ def grid_row
186
+ content_start_row
187
+ end
188
+
189
+ def hangman_col
190
+ grid_col + Grid.total_width + spacing_between
191
+ end
192
+
193
+ def hangman_row
194
+ content_start_row
195
+ end
196
+
197
+ def spacing_between
198
+ case Layout.size_category
199
+ when :large
200
+ 12
201
+ else
202
+ 6
203
+ end
204
+ end
205
+
206
+ def used_letters_row
207
+ hangman_row + Hangman.height + 2
208
+ end
209
+
210
+ def used_letters_col
211
+ hangman_col
212
+ end
213
+
214
+ def input_row
215
+ grid_row + Grid.total_height + 2
216
+ end
217
+
218
+ def input_col
219
+ grid_col
220
+ end
221
+
222
+ def timer_row
223
+ game_start_row + 2
224
+ end
225
+
226
+ def timer_col
227
+ hangman_col
228
+ end
229
+
230
+ # --- DRAWING METHODS ---
231
+
232
+ def draw_screen
233
+ Cursor.clear_screen
234
+ draw_game_border
235
+ draw_level_header
236
+ draw_timer
237
+ draw_grid
238
+ draw_hangman
239
+ draw_used_letters
240
+ draw_input_prompt
241
+ end
242
+
243
+ # Redraw only the current row being typed (for performance)
244
+ def redraw_current_row
245
+ Grid.draw_row(
246
+ row: grid_row + (@guesses.length * (Grid.dimensions[:cell_height] + Grid.dimensions[:v_gap])),
247
+ col: grid_col,
248
+ letters: [],
249
+ is_current: true,
250
+ current_input: @current_input
251
+ )
252
+ redraw_input_prompt
253
+ end
254
+
255
+ # Redraw just the input prompt area
256
+ def redraw_input_prompt
257
+ # Clear the line first
258
+ Cursor.move_to(input_row, input_col)
259
+ print " " * 50
260
+ Cursor.move_to(input_row + 1, input_col)
261
+ print " " * 50
262
+ draw_input_prompt
263
+ end
264
+
265
+ # Update timer without full redraw
266
+ def update_timer_display
267
+ Cursor.move_to(timer_row, timer_col)
268
+ print " " * 15 # Clear previous
269
+ Cursor.move_to(timer_row, timer_col)
270
+ time_text = "Time: #{format_time(elapsed_time)}"
271
+ print Colors.dim(time_text)
272
+ end
273
+
274
+ def draw_game_border
275
+ Box.draw(
276
+ row: game_start_row,
277
+ col: game_start_col,
278
+ width: game_width,
279
+ height: game_height,
280
+ color: :dim
281
+ )
282
+ end
283
+
284
+ def draw_level_header
285
+ text = "═══ LEVEL #{@level} ═══"
286
+ draw_centered(game_start_row + 1, text, color: :cyan)
287
+ end
288
+
289
+ def draw_timer
290
+ time_text = "Time: #{format_time(elapsed_time)}"
291
+ draw_at(timer_row, timer_col, time_text, color: :dim)
292
+ end
293
+
294
+ def format_time(seconds)
295
+ mins = seconds / 60
296
+ secs = seconds % 60
297
+ format("%d:%02d", mins, secs)
298
+ end
299
+
300
+ def elapsed_time
301
+ return 0 unless @start_time
302
+
303
+ (Time.now - @start_time).to_i
304
+ end
305
+
306
+ def draw_grid
307
+ Grid.draw(
308
+ row: grid_row,
309
+ col: grid_col,
310
+ guesses: @guesses,
311
+ current_row: @guesses.length,
312
+ current_input: @current_input
313
+ )
314
+ end
315
+
316
+ def draw_hangman
317
+ Hangman.draw(
318
+ row: hangman_row,
319
+ col: hangman_col,
320
+ stage: @wrong_count
321
+ )
322
+ end
323
+
324
+ def draw_used_letters
325
+ return if @used_letters.empty?
326
+
327
+ draw_at(used_letters_row, used_letters_col, "Used Letters:", color: :dim)
328
+
329
+ row_offset = 1
330
+ letters_per_row = Layout.size_category == :large ? 13 : 9
331
+
332
+ @used_letters.keys.sort.each_slice(letters_per_row).with_index do |letter_group, index|
333
+ col = used_letters_col
334
+ Cursor.move_to(used_letters_row + row_offset + index, col)
335
+
336
+ letter_group.each do |letter|
337
+ state = @used_letters[letter]
338
+ color = case state
339
+ when :correct then :green
340
+ when :present then :orange
341
+ else :red
342
+ end
343
+ print Colors.send(color, "#{letter} ")
344
+ end
345
+ end
346
+ end
347
+
348
+ def draw_input_prompt
349
+ if @game_over
350
+ if @won
351
+ draw_at(input_row, input_col, "🎉 Correct! Press any key...", color: :green)
352
+ else
353
+ draw_at(input_row, input_col, "The word was: #{@target_word}", color: :red)
354
+ draw_at(input_row + 1, input_col, "Press any key to continue...", color: :dim)
355
+ end
356
+ else
357
+ # Show current input with cursor indicator
358
+ display_input = @current_input.ljust(5, "_")
359
+ prompt = "Enter guess: #{display_input}"
360
+ draw_at(input_row, input_col, prompt, color: :white)
361
+
362
+ # Hint text
363
+ hint = if @current_input.length < 5
364
+ "(Type #{5 - @current_input.length} more letter#{"s" if 5 - @current_input.length > 1})"
365
+ else
366
+ "(Press ENTER to submit)"
367
+ end
368
+ draw_at(input_row + 1, input_col, hint, color: :dim)
369
+ end
370
+ end
371
+
372
+ # --- GAME LOGIC ---
373
+
374
+ # NOTE: Two passes are intentional - Wordle scoring requires finding exact matches
375
+ # first, then checking remaining letters against what's left in the target word.
376
+
377
+ # rubocop:disable Style/CombinableLoops
378
+ def process_guess(guess)
379
+ guess = guess.upcase
380
+ result = []
381
+ target_letters = @target_word.chars
382
+ guess_letters = guess.chars
383
+
384
+ # First pass: mark correct positions
385
+ guess_letters.each_with_index do |letter, i|
386
+ if letter == target_letters[i]
387
+ result[i] = { letter: letter, state: :correct }
388
+ target_letters[i] = nil
389
+ end
390
+ end
391
+
392
+ # Second pass: mark present/absent
393
+ guess_letters.each_with_index do |letter, i|
394
+ next if result[i]
395
+
396
+ target_index = target_letters.index(letter)
397
+ if target_index
398
+ result[i] = { letter: letter, state: :present }
399
+ target_letters[target_index] = nil
400
+ else
401
+ result[i] = { letter: letter, state: :absent }
402
+ end
403
+ end
404
+ # rubocop:enable Style/CombinableLoops
405
+
406
+ # Update used letters tracking
407
+ result.each do |r|
408
+ existing = @used_letters[r[:letter]]
409
+ next unless existing.nil? ||
410
+ (existing == :absent && r[:state] != :absent) ||
411
+ (existing == :present && r[:state] == :correct)
412
+
413
+ @used_letters[r[:letter]] = r[:state]
414
+ end
415
+
416
+ # Add to guesses
417
+ @guesses << result
418
+
419
+ # Check win condition
420
+ if result.all? { |r| r[:state] == :correct }
421
+ @won = true
422
+ @game_over = true
423
+ # Check lose condition - wrong guess increments hangman
424
+ elsif result.any? { |r| r[:state] != :correct }
425
+ @wrong_count += 1
426
+ @game_over = true if @wrong_count >= 6 || @guesses.length >= 6
427
+ end
428
+
429
+ result
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mumble
4
+ module Screens
5
+ # High scores leaderboard display
6
+ class HighScoresScreen < Base
7
+ def show
8
+ setup_screen
9
+ draw_leaderboard
10
+ wait_for_key
11
+ cleanup_screen
12
+ end
13
+
14
+ private
15
+
16
+ def content_width
17
+ case size_category
18
+ when :large then 70
19
+ when :medium then 60
20
+ else 52
21
+ end
22
+ end
23
+
24
+ def content_height
25
+ case size_category
26
+ when :large then 20
27
+ when :medium then 18
28
+ else 16
29
+ end
30
+ end
31
+
32
+ def start_row
33
+ center_row_for(content_height)
34
+ end
35
+
36
+ def draw_leaderboard
37
+ box = draw_centered_box(
38
+ row: start_row,
39
+ width: content_width,
40
+ height: content_height,
41
+ title: "HIGH SCORES",
42
+ color: :yellow
43
+ )
44
+
45
+ col = box[:col]
46
+ row = box[:row]
47
+
48
+ scores = HighScores.all
49
+
50
+ if scores.empty?
51
+ draw_empty_state(row)
52
+ else
53
+ draw_header_row(row + 2, col)
54
+ draw_scores(row + 3, col, scores)
55
+ draw_player_best(row + content_height - 2, col)
56
+ end
57
+
58
+ # Prompt
59
+ prompt_row = row + content_height + 1
60
+ draw_centered(prompt_row, "Press any key to continue...", color: :dim)
61
+ end
62
+
63
+ def draw_empty_state(row)
64
+ empty_row = row + (content_height / 2) - 1
65
+ draw_centered(empty_row, "No high scores yet!", color: :dim)
66
+ draw_centered(empty_row + 2, "Play a game to get on the leaderboard!", color: :yellow)
67
+ end
68
+
69
+ def draw_header_row(row, col)
70
+ draw_at(row, col + 3, "RANK", color: :dim)
71
+ draw_at(row, col + 10, "NAME", color: :dim)
72
+ draw_at(row, col + 28, "SCORE", color: :dim)
73
+ draw_at(row, col + 38, "LVL", color: :dim)
74
+ draw_at(row, col + 44, "DATE", color: :dim)
75
+ end
76
+
77
+ def draw_scores(start_row, col, scores)
78
+ scores.first(10).each_with_index do |score, index|
79
+ row = start_row + index
80
+ rank = index + 1
81
+
82
+ # Rank with medal for top 3
83
+ rank_text, rank_color = format_rank(rank)
84
+ draw_at(row, col + 3, rank_text, color: rank_color)
85
+
86
+ # Name (truncate if needed)
87
+ name = truncate(score[:name].to_s, 14)
88
+ name_color = score[:name] == player_name ? :cyan : :white
89
+ draw_at(row, col + 10, name, color: name_color)
90
+
91
+ # Score
92
+ score_text = format_score(score[:score])
93
+ draw_at(row, col + 28, score_text, color: :white)
94
+
95
+ # Level
96
+ draw_at(row, col + 38, score[:level].to_s, color: :dim)
97
+
98
+ # Date
99
+ date = score[:date].to_s[0, 10]
100
+ draw_at(row, col + 44, date, color: :dim)
101
+ end
102
+ end
103
+
104
+ def draw_player_best(row, col)
105
+ best_rank = HighScores.best_rank_for(player_name)
106
+ return unless best_rank
107
+
108
+ best = HighScores.best_score_for(player_name)
109
+ draw_at(row, col + 3, "Your best: ##{best_rank} with #{best[:score]} pts", color: :cyan)
110
+ end
111
+
112
+ def format_rank(rank)
113
+ case rank
114
+ when 1 then ["1st", :bold_yellow]
115
+ when 2 then ["2nd", :white]
116
+ when 3 then ["3rd", :red]
117
+ else ["#{rank}th", :dim]
118
+ end
119
+ end
120
+
121
+ def format_score(score)
122
+ score.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mumble
6
+ module Screens
7
+ # Game over screen with flashing text
8
+ class Lose < Base
9
+ GAME_OVER_SMALL = [
10
+ "╔═╗╔═╗╔╦╗╔═╗ ╔═╗╦ ╦╔═╗╦═╗",
11
+ "║ ╦╠═╣║║║║╣ ║ ║╚╗╔╝║╣ ╠╦╝",
12
+ "╚═╝╩ ╩╩ ╩╚═╝ ╚═╝ ╚╝ ╚═╝╩╚═"
13
+ ].freeze
14
+
15
+ GAME_OVER_LARGE = [
16
+ "██████╗ █████╗ ███╗ ███╗███████╗ ██████╗ ██╗ ██╗███████╗██████╗ ",
17
+ "██╔════╝ ██╔══██╗████╗ ████║██╔════╝ ██╔═══██╗██║ ██║██╔════╝██╔══██╗",
18
+ "██║ ███╗███████║██╔████╔██║█████╗ ██║ ██║██║ ██║█████╗ ██████╔╝",
19
+ "██║ ██║██╔══██║██║╚██╔╝██║██╔══╝ ██║ ██║╚██╗ ██╔╝██╔══╝ ██╔══██╗",
20
+ "╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗ ╚██████╔╝ ╚████╔╝ ███████╗██║ ██║",
21
+ "╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝"
22
+ ].freeze
23
+
24
+ def initialize(word:, level:, guesses:)
25
+ super()
26
+ @word = word
27
+ @level = level
28
+ @guesses = guesses
29
+ end
30
+
31
+ def show
32
+ Cursor.hide
33
+ draw_screen
34
+ flash_game_over
35
+ Input.wait_for_any_key
36
+ end
37
+
38
+ private
39
+
40
+ def game_over_text
41
+ Layout.size_category == :large ? GAME_OVER_LARGE : GAME_OVER_SMALL
42
+ end
43
+
44
+ def text_start_row
45
+ (Screen.height - 25) / 2
46
+ end
47
+
48
+ def text_start_col
49
+ Screen.center_col(game_over_text.first.length)
50
+ end
51
+
52
+ def draw_screen
53
+ Cursor.clear_screen
54
+ draw_game_over_text(:red)
55
+ draw_content
56
+ end
57
+
58
+ def draw_game_over_text(color)
59
+ game_over_text.each_with_index do |line, index|
60
+ Cursor.move_to(text_start_row + index, text_start_col)
61
+ print Colors.send(color, line)
62
+ end
63
+ end
64
+
65
+ def flash_game_over
66
+ # Flash between red and dim red
67
+ 6.times do
68
+ draw_game_over_text(:red)
69
+ pause(0.3)
70
+ draw_game_over_text(:dim)
71
+ pause(0.2)
72
+ end
73
+ # End on solid red
74
+ draw_game_over_text(:red)
75
+ end
76
+
77
+ def draw_content
78
+ content_row = text_start_row + game_over_text.length + 3
79
+
80
+ # Skull emoji line
81
+ draw_centered(content_row, "💀 💀 💀", color: :red)
82
+
83
+ # Word revealed
84
+ draw_centered(content_row + 3, "The word was:", color: :dim)
85
+ draw_centered(content_row + 5, @word, color: :cyan)
86
+
87
+ # Stats
88
+ draw_centered(content_row + 8, "Level: #{@level} | Guesses: #{@guesses}/6", color: :dim)
89
+
90
+ # Encouragement
91
+ messages = [
92
+ "Better luck next time!",
93
+ "Don't give up!",
94
+ "You'll get it next time!",
95
+ "Practice makes perfect!"
96
+ ]
97
+ draw_centered(content_row + 11, messages.sample, color: :yellow)
98
+
99
+ # Continue prompt
100
+ draw_centered(content_row + 14, "Press any key to continue...", color: :dim)
101
+ end
102
+
103
+ def pause(seconds)
104
+ sleep(seconds)
105
+ end
106
+ end
107
+ end
108
+ end