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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screen"
4
+
5
+ module Mumble
6
+ # Calculates dynamic positions for all game elements based on terminal size
7
+ # All positions are calculated from center, so game is always centered
8
+ module Layout
9
+ # Dimensions for game elements
10
+ CELL_WIDTH = 5 # Width of a single letter cell (includes border)
11
+ CELL_HEIGHT = 3 # Height of a single letter cell (includes border)
12
+ GRID_COLS = 5 # Letters per word
13
+ GRID_ROWS = 6 # Number of guesses
14
+ GRID_GAP = 1 # Space between cells
15
+
16
+ HANGMAN_WIDTH = 29 # Width of hangman area
17
+ HANGMAN_HEIGHT = 23 # Height of hangman area
18
+
19
+ GAME_SPACING = 8 # Space between grid and hangman
20
+
21
+ class << self
22
+ # Total width of the word grid
23
+ def grid_width
24
+ (CELL_WIDTH * GRID_COLS) + (GRID_GAP * (GRID_COLS - 1))
25
+ end
26
+
27
+ # Total height of the word grid
28
+ def grid_height
29
+ (CELL_HEIGHT * GRID_ROWS) + (GRID_GAP * (GRID_ROWS - 1))
30
+ end
31
+
32
+ # Total width of the entire game area (grid + spacing + hangman)
33
+ def game_width
34
+ grid_width + GAME_SPACING + HANGMAN_WIDTH
35
+ end
36
+
37
+ # Total height of the game area (including header and footer)
38
+ def game_height
39
+ [grid_height, HANGMAN_HEIGHT].max + 6 # +6 for header, footer, spacing
40
+ end
41
+
42
+ # Starting column for the entire game area (left edge)
43
+ def game_start_col
44
+ Screen.center_col(game_width)
45
+ end
46
+
47
+ # Starting row for the entire game area (top edge)
48
+ def game_start_row
49
+ [(Screen.height - game_height) / 2, 2].max
50
+ end
51
+
52
+ # --- HEADER POSITIONS ---
53
+
54
+ # Level display position (centered at top)
55
+ def level_row
56
+ game_start_row
57
+ end
58
+
59
+ def level_col
60
+ Screen.center_col(15) # "━━━ LEVEL XX ━━━" is about 15 chars
61
+ end
62
+
63
+ # Timer position (top right of game area)
64
+ def timer_row
65
+ game_start_row
66
+ end
67
+
68
+ def timer_col
69
+ game_start_col + game_width - 10 # "⏱️ 0:00" is about 10 chars
70
+ end
71
+
72
+ # --- GRID POSITIONS ---
73
+
74
+ # Top-left corner of the word grid
75
+ def grid_row
76
+ game_start_row + 3 # Below header
77
+ end
78
+
79
+ def grid_col
80
+ game_start_col
81
+ end
82
+
83
+ # Get position for a specific cell in the grid
84
+ # cell_row: 0-5 (which guess row)
85
+ # cell_col: 0-4 (which letter position)
86
+ def cell_position(cell_row, cell_col)
87
+ row = grid_row + (cell_row * (CELL_HEIGHT + GRID_GAP))
88
+ col = grid_col + (cell_col * (CELL_WIDTH + GRID_GAP))
89
+ { row: row, col: col }
90
+ end
91
+
92
+ # --- HANGMAN POSITIONS ---
93
+
94
+ # Top-left corner of the hangman area
95
+ def hangman_row
96
+ grid_row
97
+ end
98
+
99
+ def hangman_col
100
+ grid_col + grid_width + GAME_SPACING
101
+ end
102
+
103
+ # Used letters display (below hangman)
104
+ def used_letters_row
105
+ hangman_row + HANGMAN_HEIGHT + 1
106
+ end
107
+
108
+ def used_letters_col
109
+ hangman_col
110
+ end
111
+
112
+ # --- INPUT/MESSAGE POSITIONS ---
113
+
114
+ # Input prompt position (below grid)
115
+ def input_row
116
+ grid_row + grid_height + 2
117
+ end
118
+
119
+ def input_col
120
+ grid_col
121
+ end
122
+
123
+ # Message area (for errors, hints)
124
+ def message_row
125
+ input_row + 2
126
+ end
127
+
128
+ def message_col
129
+ game_start_col
130
+ end
131
+
132
+ # --- UTILITY METHODS ---
133
+
134
+ # Check if terminal can fit the game
135
+ def fits?
136
+ Screen.width >= game_width + 4 && Screen.height >= game_height + 2
137
+ end
138
+
139
+ # Get a summary of all positions (useful for debugging)
140
+ def debug_info
141
+ {
142
+ screen: { width: Screen.width, height: Screen.height },
143
+ game_area: { width: game_width, height: game_height },
144
+ game_start: { row: game_start_row, col: game_start_col },
145
+ grid_start: { row: grid_row, col: grid_col },
146
+ hangman_start: { row: hangman_row, col: hangman_col },
147
+ input: { row: input_row, col: input_col },
148
+ fits: fits?
149
+ }
150
+ end
151
+
152
+ # --- SCALING SYSTEM ---
153
+
154
+ # Terminal size categories
155
+ def size_category
156
+ if Screen.width >= 160 && Screen.height >= 45
157
+ :large
158
+ elsif Screen.width >= 100 && Screen.height >= 30
159
+ :medium
160
+ else
161
+ :small
162
+ end
163
+ end
164
+
165
+ # Scale factor based on terminal size (1.0 = baseline at 80x24)
166
+ def scale_factor
167
+ width_scale = Screen.width / 80.0
168
+ height_scale = Screen.height / 24.0
169
+ [width_scale, height_scale].min.clamp(1.0, 2.5)
170
+ end
171
+
172
+ # Scale a value based on terminal size
173
+ def scale(value)
174
+ (value * scale_factor).round
175
+ end
176
+
177
+ # Get scaled box dimensions for menus
178
+ def menu_box_width
179
+ case size_category
180
+ when :large then 50
181
+ when :medium then 40
182
+ else 30
183
+ end
184
+ end
185
+
186
+ def menu_box_padding
187
+ case size_category
188
+ when :large then 3
189
+ when :medium then 2
190
+ else 1
191
+ end
192
+ end
193
+
194
+ def menu_item_spacing
195
+ case size_category
196
+ when :large then 2
197
+ when :medium then 1
198
+ else 0
199
+ end
200
+ end
201
+
202
+ # Should we use the big logo?
203
+ def use_large_logo?
204
+ %i[large medium].include?(size_category)
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mumble
4
+ # Calculates game scores based on performance
5
+ module Scorer
6
+ # Base points for winning
7
+ BASE_SCORE = 1000
8
+
9
+ # Points deducted per guess used
10
+ GUESS_PENALTY = 100
11
+
12
+ # Points deducted per 30 seconds
13
+ TIME_PENALTY = 50
14
+
15
+ # Bonus multiplier per level
16
+ LEVEL_MULTIPLIER = 0.1
17
+
18
+ class << self
19
+ # Calculate score for a winning game
20
+ # guesses: number of guesses used (1-6)
21
+ # time: seconds taken to complete
22
+ # level: current level (for multiplier)
23
+ def calculate(guesses:, time:, level:)
24
+ return 0 if guesses > 6
25
+
26
+ # Start with base score
27
+ score = BASE_SCORE
28
+
29
+ # Deduct for guesses used (first guess is free)
30
+ guess_deduction = (guesses - 1) * GUESS_PENALTY
31
+ score -= guess_deduction
32
+
33
+ # Deduct for time taken (every 30 seconds)
34
+ time_blocks = (time / 30.0).floor
35
+ time_deduction = time_blocks * TIME_PENALTY
36
+ score -= time_deduction
37
+
38
+ # Apply level multiplier (10% bonus per level after 1)
39
+ if level > 1
40
+ multiplier = 1 + ((level - 1) * LEVEL_MULTIPLIER)
41
+ score = (score * multiplier).round
42
+ end
43
+
44
+ # Never go below 100 for a win
45
+ [score, 100].max
46
+ end
47
+
48
+ # Get a breakdown of the score calculation
49
+ def breakdown(guesses:, time:, level:)
50
+ guess_deduction = (guesses - 1) * GUESS_PENALTY
51
+ time_blocks = (time / 30.0).floor
52
+ time_deduction = time_blocks * TIME_PENALTY
53
+ multiplier = level > 1 ? 1 + ((level - 1) * LEVEL_MULTIPLIER) : 1.0
54
+
55
+ base = BASE_SCORE - guess_deduction - time_deduction
56
+ final = [(base * multiplier).round, 100].max
57
+
58
+ {
59
+ base_score: BASE_SCORE,
60
+ guess_penalty: -guess_deduction,
61
+ time_penalty: -time_deduction,
62
+ level_multiplier: multiplier,
63
+ final_score: final
64
+ }
65
+ end
66
+
67
+ # Get rating based on score
68
+ def rating(score)
69
+ case score
70
+ when 900.. then "⭐⭐⭐ PERFECT!"
71
+ when 700..899 then "⭐⭐ GREAT!"
72
+ when 500..699 then "⭐ GOOD"
73
+ else "NICE TRY"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-screen"
4
+
5
+ module Mumble
6
+ # Handles terminal screen dimensions and validation
7
+ module Screen
8
+ # Minimum dimensions required for the game
9
+ MIN_WIDTH = 80
10
+ MIN_HEIGHT = 24
11
+
12
+ class << self
13
+ # Get current terminal width
14
+ def width
15
+ TTY::Screen.width
16
+ end
17
+
18
+ # Get current terminal height
19
+ def height
20
+ TTY::Screen.height
21
+ end
22
+
23
+ # Check if terminal meets minimum size requirements
24
+ def valid_size?
25
+ width >= MIN_WIDTH && height >= MIN_HEIGHT
26
+ end
27
+
28
+ # Get current dimensions as a hash
29
+ def dimensions
30
+ { width: width, height: height }
31
+ end
32
+
33
+ # Calculate horizontal center position for text of given length
34
+ def center_col(text_length)
35
+ [(width - text_length) / 2, 1].max
36
+ end
37
+
38
+ # Calculate vertical center position
39
+ def center_row
40
+ height / 2
41
+ end
42
+
43
+ # Attempt to resize terminal (works on some terminals)
44
+ # Returns true if resize was attempted
45
+ def attempt_resize
46
+ print "\e[8;#{MIN_HEIGHT};#{MIN_WIDTH}t"
47
+ true
48
+ end
49
+
50
+ # Get a friendly size description
51
+ def size_description
52
+ "#{width}x#{height}"
53
+ end
54
+
55
+ # Get required size description
56
+ def required_size_description
57
+ "#{MIN_WIDTH}x#{MIN_HEIGHT}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mumble
4
+ module Screens
5
+ # Base class for all game screens
6
+ # Provides common functionality and structure
7
+ class Base
8
+ attr_reader :config
9
+
10
+ def initialize(config = nil)
11
+ @config = config || Config.load
12
+ end
13
+
14
+ # Main entry point - override in subclasses
15
+ def show
16
+ raise NotImplementedError, "Subclasses must implement #show"
17
+ end
18
+
19
+ private
20
+
21
+ # Clear screen and prepare for drawing
22
+ def setup_screen
23
+ Cursor.clear_screen
24
+ Cursor.hide
25
+ end
26
+
27
+ # Restore cursor before leaving screen
28
+ def cleanup_screen
29
+ Cursor.show
30
+ end
31
+
32
+ # Draw centered text at a specific row
33
+ def draw_centered(row, text, color: nil)
34
+ col = Screen.center_col(text.length)
35
+ Cursor.move_to(row, col)
36
+ print color ? Colors.send(color, text) : text
37
+ end
38
+
39
+ # Draw text at specific position
40
+ def draw_at(row, col, text, color: nil)
41
+ Cursor.move_to(row, col)
42
+ if color
43
+ colored_text = if Colors.respond_to?(color)
44
+ Colors.send(color, text)
45
+ else
46
+ text
47
+ end
48
+ print colored_text
49
+ else
50
+ print text
51
+ end
52
+ end
53
+
54
+ # Wait for user to press any key
55
+ def wait_for_key
56
+ Input.wait_for_any_key
57
+ end
58
+
59
+ # Wait for user to press Enter
60
+ def wait_for_enter
61
+ Input.wait_for_enter
62
+ end
63
+
64
+ # Get player name from config
65
+ def player_name
66
+ config[:player_name]
67
+ end
68
+
69
+ # Short pause for animations
70
+ def pause(seconds = 0.5)
71
+ sleep(seconds)
72
+ end
73
+
74
+ # Truncate text to fit within max length
75
+ def truncate(text, max_length, suffix: "...")
76
+ return text if text.length <= max_length
77
+
78
+ text[0, max_length - suffix.length] + suffix
79
+ end
80
+
81
+ # --- SCALING HELPERS ---
82
+
83
+ # Get current size category
84
+ def size_category
85
+ Layout.size_category
86
+ end
87
+
88
+ # Check if large terminal
89
+ def large_screen?
90
+ size_category == :large
91
+ end
92
+
93
+ # Check if medium or larger terminal
94
+ def medium_screen?
95
+ %i[large medium].include?(size_category)
96
+ end
97
+
98
+ # Get scaled value
99
+ def scale(value)
100
+ Layout.scale(value)
101
+ end
102
+
103
+ # Get menu box width
104
+ def box_width
105
+ Layout.menu_box_width
106
+ end
107
+
108
+ # Get item spacing for menus
109
+ def item_spacing
110
+ Layout.menu_item_spacing
111
+ end
112
+
113
+ # Get box padding
114
+ def box_padding
115
+ Layout.menu_box_padding
116
+ end
117
+
118
+ # Draw a horizontal divider line (centered)
119
+ def draw_divider(row, width: nil, color: :dim)
120
+ w = width || box_width
121
+ line = "─" * w
122
+ draw_centered(row, line, color: color)
123
+ end
124
+
125
+ # Draw a box centered on screen
126
+ def draw_centered_box(row:, width:, height:, color: :dim, title: nil)
127
+ col = Screen.center_col(width)
128
+ if title
129
+ Box.draw_with_title(row: row, col: col, width: width, height: height, title: title, color: color)
130
+ else
131
+ Box.draw(row: row, col: col, width: width, height: height, color: color)
132
+ end
133
+ { row: row, col: col, width: width, height: height }
134
+ end
135
+
136
+ # Calculate vertical center offset for content of given height
137
+ def center_row_for(content_height)
138
+ Screen.center_row - (content_height / 2)
139
+ end
140
+ end
141
+ end
142
+ end