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,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
|