bettys 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c28f8fe1ef7934ce8e124984faf7e16e944a349766d861aa02a170ac83f5306c
4
+ data.tar.gz: b36e4909cbde89fca7faf87824aa101b25f08ee839ff13121fc8b11c5108570a
5
+ SHA512:
6
+ metadata.gz: 976bb1f0f404d7ecdface6e19d629649514cb253be6ab936200ad46af152cfbb8a33179483b1605fa9dfe5b45d39ba3ff0375f7bccb316a0a3bc1eff742c352f
7
+ data.tar.gz: e647e80d96b215e825ed0fba41b0f38e8418df1e9f56189c11b2b0847b6868f403efbc1583b4b8c6e4fa633b66841c08cc684da0ef242f9489c590cbcc6c2907
data/asset/game.png ADDED
Binary file
data/asset/over.png ADDED
Binary file
data/bin/bettys ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bettys"
4
+
5
+ opts = Optimist.options do
6
+ opt :level, "Starting level (1-15)", type: :int, default: 1, short: "n"
7
+ opt :mode, "Game mode", type: :string, default: "marathon"
8
+ end
9
+
10
+ %w[marathon sprint ultra infinite].include?(opts[:mode]).or do
11
+ err "Invalid mode: %s. Valid: marathon, sprint, ultra, infinite", opts[:mode]
12
+ end
13
+
14
+ opts[:level].between?(1, 15).or do
15
+ err "Level must be between 1 and 15, got: %d", opts[:level]
16
+ end
17
+
18
+ COLOR_PAIR = {}
19
+ game = nil
20
+ game_over = false
21
+
22
+ begin
23
+ Curses.init_screen
24
+ Curses.start_color
25
+ Curses.use_default_colors
26
+
27
+ COLOR.each_with_index do |(piece, color), i|
28
+ Curses.init_pair(i + 1, Curses.const_get("COLOR_#{color}"), Curses::COLOR_BLACK)
29
+ COLOR_PAIR[piece] = i + 1
30
+ end
31
+
32
+ Curses.raw
33
+ Curses.stdscr.keypad(true)
34
+ Curses.noecho
35
+ Curses.timeout = 20
36
+ Curses.curs_set(0)
37
+
38
+ game = Bettys.new(opts[:mode].to_sym, opts[:level])
39
+ game.imprint
40
+ game.start
41
+
42
+ loop do
43
+ game.draw
44
+ Curses.refresh
45
+
46
+ if game.over?
47
+ game.stop
48
+ game_over = true
49
+ break
50
+ end
51
+
52
+ case Curses.getch
53
+ when ?h, Curses::Key::LEFT
54
+ game.sync do
55
+ game.piece.move!(-1)
56
+ .tif { game.notify_lock_reset }
57
+ end
58
+ when ?l, Curses::Key::RIGHT
59
+ game.sync do
60
+ game.piece.move!(+1)
61
+ .tif { game.notify_lock_reset }
62
+ end
63
+ when ?k
64
+ game.sync do
65
+ game.piece.rotate!(clockwise: true)
66
+ .tif { game.notify_lock_reset }
67
+ end
68
+ when ?K
69
+ game.sync do
70
+ game.piece.rotate!(clockwise: false)
71
+ .tif { game.notify_lock_reset }
72
+ end
73
+ when ?j, Curses::Key::DOWN
74
+ game.soft_drop
75
+ when ?J
76
+ game.drop
77
+ when ?H
78
+ game.dash(-1)
79
+ when ?L
80
+ game.dash(+1)
81
+ when ?c
82
+ game.hold
83
+ when " "
84
+ game.toggle
85
+ when ?q, 3
86
+ if game.paused
87
+ break
88
+ else
89
+ game.toggle
90
+ end
91
+ when Curses::KEY_RESIZE
92
+ Curses.clear
93
+ game.imprint
94
+ end
95
+ end
96
+ ensure
97
+ game&.stop
98
+ Curses.close_screen
99
+
100
+ game.show_game_over if game_over && game
101
+ end
data/lib/bettys.rb ADDED
@@ -0,0 +1,9 @@
1
+ require "curses"
2
+ require "optimist"
3
+ require "emanlib"
4
+
5
+ require_relative "consts"
6
+ require_relative "purse"
7
+ require_relative "board"
8
+ require_relative "pieces"
9
+ require_relative "game"
data/lib/board.rb ADDED
@@ -0,0 +1,52 @@
1
+ Action = Enum[{ Single: 1 }, :Double, :Triple, :Bettys]
2
+
3
+ class Board
4
+ attr_reader :matrix
5
+
6
+ SCORE = {
7
+ Action.Single => 100,
8
+ Action.Double => 300,
9
+ Action.Triple => 500,
10
+ Action.Bettys => 800,
11
+ }.freeze
12
+
13
+ def initialize
14
+ # Each row must be a separate array instance
15
+ @matrix = Array.new(40) { Array.new(10) }
16
+ end
17
+
18
+ # Check if a point is valid (in bounds and empty)
19
+ def valid?(point)
20
+ point.x.between?(0, 9) &&
21
+ point.y.between?(0, 39) &&
22
+ @matrix[point.y][point.x].nil?
23
+ end
24
+
25
+ # Lock a piece into the matrix
26
+ def absorb(piece)
27
+ piece.niños.each do |niño|
28
+ @matrix[niño.y][niño.x] = COLOR_PAIR[piece.shape]
29
+ end
30
+ end
31
+
32
+ # Clear completed lines, return [score, count]
33
+ def simplify
34
+ cleared = []
35
+
36
+ # Check visible rows (below skyline)
37
+ ((SKYLINE_INDEX + 1)...40).each do |y|
38
+ cleared << y if @matrix[y].all?
39
+ end
40
+
41
+ # Remove cleared rows and add empty rows at top
42
+ cleared.each do |y|
43
+ @matrix.delete_at(y)
44
+ @matrix.unshift(Array.new(10))
45
+
46
+ # Adjust remaining indices since we modified the array
47
+ cleared.map! { |i| i >= y ? i : i }
48
+ end
49
+
50
+ [SCORE[cleared.size] || 0, cleared.size]
51
+ end
52
+ end
data/lib/consts.rb ADDED
@@ -0,0 +1,92 @@
1
+ SKYLINE_INDEX = 19
2
+
3
+ MINIMUM = let **{
4
+ HEIGHT: 24,
5
+ WIDTH: 58,
6
+ }
7
+
8
+ COLOR = {
9
+ I: :CYAN,
10
+ J: :BLUE,
11
+ L: :YELLOW,
12
+ O: :YELLOW,
13
+ S: :GREEN,
14
+ T: :MAGENTA,
15
+ Z: :RED,
16
+ }
17
+
18
+ module Box
19
+ def self.[](art)
20
+ art = art.chars
21
+ let **{
22
+ h: art[1], v: art[4],
23
+ top: { left: art[0], right: art[2] },
24
+ bottom: { left: art[3], right: art[5] },
25
+ }
26
+ end
27
+
28
+ NORMAL = Box["┌─┐└│┘"]
29
+ THICK = Box["┏━┓┗┃┛"]
30
+ DOTTED = Box["┌┄┐└┆┘"]
31
+ DOUBLE = Box["╔═╗╚║╝"]
32
+ end
33
+
34
+ GAME_OVER_ART = <<~ART.chomp.freeze
35
+ ⠀⠀⠀⢀⣾⣦⣤⣤⣤⡀⣀⣤⣄⣀⣀⡄⠀⠀⠀⠀
36
+ ⠀⣆⣴⣿⣿⣿⣿⣿⢏⣾⣿⣿⣿⣿⣿⣿⣤⡀⡀⠀
37
+ ⠀⣼⡿⠛⠿⠍⠛⠟⠾⠛⢛⣿⠻⢿⣿⣿⣿⣿⡟⠀
38
+ ⢠⡟⠋⢀⠌⠀⠀⠀⠀⠀⠒⢄⠀⠲⠟⣿⣿⣿⡇⠀
39
+ ⠀⠘⡁⢊⣆⣄⡀⠀⠀⠀⠀⠈⠢⠀⠰⣼⣿⣿⠷⠃
40
+ ⠀⢲⡟⣹⠀⢻⡆⠀⠀⢠⣂⢉⠆⢀⢀⣼⡿⠋⠀⠀
41
+ ⠀⢿⣇⠀⠙⠉⠀⡄⠀⠈⠉⠉⠁⠘⣿⣿⡧⠀⠀⠀
42
+ ⠀⠀⢤⡭⠶⢒⡠⢶⡦⠒⠤⣀⣀⠴⠫⣂⠇⠀⠀⠀
43
+ ⠀⠀⠻⠱⣒⣲⢉⠜⠁⠀⠀⠀⢳⣄⠀⠀⠀⠀⠀⠀
44
+ ⠀⠀⠀⠀⡇⠸⠃⠀⡠⠒⠀⠀⠀⡏⠳⡀⠀⠀⠀⠀
45
+ ⠀⠀⠀⠀⡇⠀⣠⣾⣷⣄⠀⠀⠀⢰⠀⡸⠀⠀⠀⠀
46
+ ⠀⠀⠀⠀⠈⠉⠸⣿⣿⣿⣗⢤⡀⢸⣴⠃⠀⠀⠀⠀
47
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠛⠃⠛⠛⠃⠀⠀⠀
48
+ ART
49
+
50
+ GAME_OVER_MESSAGES = [
51
+ "Thanks for playing honey",
52
+ "See you again sweetie!",
53
+ "You did great darling!",
54
+ "Come back soon love!",
55
+ "That was beautiful babe!",
56
+ "You're a star, gorgeous!",
57
+ "Until next time cutie!",
58
+ "What a ride sweetheart!",
59
+ "You played your heart out!",
60
+ "Rest up, you earned it!",
61
+ "Until next time, sugar",
62
+ "Sweet dreams, beautiful",
63
+ "Come back soon, cutie",
64
+ "You're a star, sweetheart",
65
+ "Miss you already, babe",
66
+ "That was fun, gorgeous",
67
+ "You're amazing, love",
68
+ "Lovely playing with you",
69
+ "You light up my screen",
70
+ "Be back soon, angel",
71
+
72
+ ].freeze
73
+
74
+ def err(message, *args)
75
+ $stderr.printf "Error: #{message}\n", *args
76
+ exit 1
77
+ end
78
+
79
+ def origin
80
+ [
81
+ (Curses.lines - MINIMUM.HEIGHT) / 2,
82
+ (Curses.cols - MINIMUM.WIDTH) / 2,
83
+ ]
84
+ end
85
+
86
+ def typewriter(text, delay: 0.04)
87
+ text.each_char do |c|
88
+ print c
89
+ $stdout.flush
90
+ sleep delay
91
+ end
92
+ end
data/lib/game.rb ADDED
@@ -0,0 +1,355 @@
1
+ class Curses::Window
2
+ def print(s, point = nil, color = nil)
3
+ setpos(point.y, point.x) if point
4
+
5
+ if color
6
+ attron(Curses.color_pair(color))
7
+ addstr(s)
8
+ attroff(Curses.color_pair(color))
9
+ else
10
+ addstr(s)
11
+ end
12
+ end
13
+
14
+ def draw_box(y, x, width, height, art = Box::NORMAL)
15
+ setpos(y, x)
16
+ addstr(art.top.left + art.h * (width - 2) + art.top.right)
17
+
18
+ (1...height - 1).each do |row|
19
+ setpos(y + row, x)
20
+ addstr(art.v)
21
+ setpos(y + row, x + width - 1)
22
+ addstr(art.v)
23
+ end
24
+
25
+ setpos(y + height - 1, x)
26
+ addstr(art.bottom.left + art.h * (width - 2) + art.bottom.right)
27
+ end
28
+
29
+ def clear_area(y, x, width, height)
30
+ blank = " " * width
31
+ height.times do |row|
32
+ setpos(y + row, x)
33
+ addstr(blank)
34
+ end
35
+ end
36
+ end
37
+
38
+ LOCK_DELAY = 0.5
39
+ MAX_LOCK_RESETS = 15
40
+
41
+ class Bettys
42
+ attr_reader :piece
43
+ attr_accessor :paused, :score
44
+
45
+ def initialize(mode, level = 1)
46
+ @mode = mode
47
+ @board = Board.new
48
+ @purse = Purse.new(@board)
49
+
50
+ @piece = @purse.pick
51
+ @hold = nil
52
+ @can_hold = true
53
+
54
+ @score = 0
55
+ @time = mode == :ultra ? 120 : 0
56
+ @line = 0
57
+ @start_level = level
58
+ @level = level
59
+
60
+ @mutex = Mutex.new
61
+ @paused = false
62
+ @running = false
63
+
64
+ @lock_timer = nil
65
+ @lock_resets = 0
66
+ @grounded = false
67
+ end
68
+
69
+ def start
70
+ return if @running
71
+
72
+ @running = true
73
+
74
+ @gravity_thread = Thread.new do
75
+ while @running
76
+ sleep speed
77
+ next if @paused || over?
78
+
79
+ sync do
80
+ if @piece.slide!
81
+ @grounded = false
82
+ @score += 1
83
+ else
84
+ start_lock_timer
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ @lock_thread = Thread.new do
91
+ while @running
92
+ sleep 0.05
93
+ next if @paused || over?
94
+
95
+ sync do
96
+ if @lock_timer && Time.now >= @lock_timer
97
+ lock_piece
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ @timer_thread = Thread.new do
104
+ while @running
105
+ sleep 1
106
+ next if @paused || over?
107
+
108
+ @time += @mode == :ultra ? -1 : 1
109
+ end
110
+ end
111
+ end
112
+
113
+ def stop
114
+ @running = false
115
+ [@gravity_thread, @lock_thread, @timer_thread].each do |t|
116
+ t&.join(1)
117
+ end
118
+ end
119
+
120
+ def toggle
121
+ @paused = !@paused
122
+ end
123
+
124
+ def sync
125
+ @mutex.synchronize { yield if block_given? }
126
+ end
127
+
128
+ def over?
129
+ @piece.nil? || !@piece.valid? ||
130
+ case @mode
131
+ when :marathon then @line >= 150
132
+ when :sprint then @line >= 40
133
+ when :ultra then @time <= 0
134
+ when :infinite then false
135
+ end
136
+ end
137
+
138
+ def drop
139
+ count = 0
140
+ sync do
141
+ count += 1 while @piece.slide!
142
+ lock_piece
143
+ end
144
+ @score += 2 * count
145
+ end
146
+
147
+ def soft_drop
148
+ sync do
149
+ @piece.slide!.tif { @score += 1 }
150
+ end
151
+ end
152
+
153
+ def dash(direction)
154
+ sync do
155
+ nil while @piece.move!(direction)
156
+ notify_lock_reset
157
+ end
158
+ end
159
+
160
+ def hold
161
+ sync do
162
+ return unless @can_hold
163
+
164
+ @hold, @piece = @piece, @hold || @purse.pick
165
+
166
+ @hold.orientation = Face.↑
167
+ @hold.center = Point[20, 4]
168
+
169
+ @can_hold = false
170
+ reset_lock_state
171
+ end
172
+ end
173
+
174
+ def notify_lock_reset
175
+ return unless @grounded
176
+
177
+ if @lock_resets < MAX_LOCK_RESETS
178
+ @lock_timer = Time.now + LOCK_DELAY
179
+ @lock_resets += 1
180
+ end
181
+ end
182
+
183
+ def imprint
184
+ oy, ox = origin
185
+ win = Curses.stdscr
186
+
187
+ win.print("HOLD", Point[oy, ox + 6])
188
+ win.draw_box(oy + 1, ox + 2, 14, 6, Box::DOTTED)
189
+
190
+ win.print("SCORE", Point[oy + 8, ox + 3])
191
+ win.print("TIME", Point[oy + 11, ox + 3])
192
+ win.print("LINE", Point[oy + 14, ox + 3])
193
+ win.print("LEVEL", Point[oy + 17, ox + 3])
194
+
195
+ win.draw_box(oy + 1, ox + 18, 22, 22, Box::DOUBLE)
196
+
197
+ win.print("NEXT", Point[oy, ox + 46])
198
+ win.draw_box(oy + 1, ox + 42, 14, 18, Box::NORMAL)
199
+ end
200
+
201
+ def draw
202
+ oy, ox = origin
203
+ win = Curses.stdscr
204
+
205
+ draw_hold(oy, ox, win)
206
+ draw_stats(oy, ox, win)
207
+ draw_matrix(oy, ox, win)
208
+ draw_active_piece(oy, ox, win)
209
+ draw_next(oy, ox, win)
210
+ draw_pause_overlay(oy, ox, win) if @paused
211
+ end
212
+
213
+ # Called AFTER Curses.close_screen — prints to normal stdout
214
+ def show_game_over
215
+ puts
216
+ puts GAME_OVER_ART
217
+ puts
218
+
219
+ sleep 0.3
220
+
221
+ typewriter " Score: #{@score}"
222
+ puts
223
+ typewriter " Lines: #{@line}"
224
+ puts
225
+ typewriter " Level: #{@level}"
226
+ puts
227
+ typewriter " Time: #{format_time(@time)}"
228
+ puts
229
+ puts
230
+
231
+ sleep 0.5
232
+
233
+ message = GAME_OVER_MESSAGES.sample
234
+ typewriter " #{message} "
235
+
236
+ sleep 0.4
237
+ print "💋"
238
+ $stdout.flush
239
+
240
+ puts
241
+ puts
242
+ end
243
+
244
+ def speed
245
+ base = 0.8 - ((@level - 1) * 0.007)
246
+ base ** (@level - 1)
247
+ end
248
+
249
+ private
250
+
251
+ def start_lock_timer
252
+ unless @grounded
253
+ @grounded = true
254
+ @lock_timer = Time.now + LOCK_DELAY
255
+ @lock_resets = 0
256
+ end
257
+ end
258
+
259
+ def reset_lock_state
260
+ @grounded = false
261
+ @lock_timer = nil
262
+ @lock_resets = 0
263
+ end
264
+
265
+ def lock_piece
266
+ @board.absorb(@piece)
267
+ score, count = @board.simplify
268
+
269
+ @score += score * @level
270
+ @line += count
271
+ @level = [@start_level + (@line / 10), 15].min
272
+
273
+ @piece = @purse.pick
274
+ @can_hold = true
275
+ reset_lock_state
276
+ end
277
+
278
+ def draw_hold(oy, ox, win)
279
+ win.clear_area(oy + 2, ox + 3, 12, 4)
280
+ @hold&.display(oy + 3, ox + 6)
281
+ end
282
+
283
+ def draw_stats(oy, ox, win)
284
+ win.print(@score.to_s.rjust(12), Point[oy + 9, ox + 3])
285
+ win.print(format_time(@time).rjust(12), Point[oy + 12, ox + 3])
286
+ win.print(@line.to_s.rjust(12), Point[oy + 15, ox + 3])
287
+ win.print(@level.to_s.rjust(12), Point[oy + 18, ox + 3])
288
+ end
289
+
290
+ def format_time(seconds)
291
+ minutes = seconds.abs / 60
292
+ secs = seconds.abs % 60
293
+ "%d:%02d" % [minutes, secs]
294
+ end
295
+
296
+ def draw_matrix(oy, ox, win)
297
+ (SKYLINE_INDEX + 1...40).each do |row|
298
+ screen_y = oy + 1 + row - SKYLINE_INDEX
299
+
300
+ 10.times do |col|
301
+ screen_x = ox + 19 + col * 2
302
+ cell = @board.matrix[row][col]
303
+
304
+ win.setpos(screen_y, screen_x)
305
+
306
+ if cell
307
+ win.attron(Curses.color_pair(cell))
308
+ win.addstr("██")
309
+ win.attroff(Curses.color_pair(cell))
310
+ else
311
+ win.addstr(" ")
312
+ end
313
+ end
314
+ end
315
+ end
316
+
317
+ def draw_active_piece(oy, ox, win)
318
+ sync do
319
+ return unless @piece
320
+
321
+ color = COLOR_PAIR[@piece.shape]
322
+
323
+ @piece.ghost.each do |niño|
324
+ next if niño.y <= SKYLINE_INDEX
325
+
326
+ win.setpos(oy + 1 + niño.y - SKYLINE_INDEX, ox + 19 + niño.x * 2)
327
+ win.attron(Curses.color_pair(color))
328
+ win.addstr("░░")
329
+ win.attroff(Curses.color_pair(color))
330
+ end
331
+
332
+ @piece.niños.each do |niño|
333
+ next if niño.y <= SKYLINE_INDEX
334
+
335
+ win.setpos(oy + 1 + niño.y - SKYLINE_INDEX, ox + 19 + niño.x * 2)
336
+ win.attron(Curses.color_pair(color))
337
+ win.addstr("██")
338
+ win.attroff(Curses.color_pair(color))
339
+ end
340
+ end
341
+ end
342
+
343
+ def draw_next(oy, ox, win)
344
+ win.clear_area(oy + 2, ox + 43, 12, 16)
345
+
346
+ @purse.bag.last(5).reverse_each.with_index do |piece, i|
347
+ piece.display(oy + 3 + i * 3, ox + 46)
348
+ end
349
+ end
350
+
351
+ def draw_pause_overlay(oy, ox, win)
352
+ win.setpos(oy + 11, ox + 24)
353
+ win.addstr(" PAUSED ")
354
+ end
355
+ end
data/lib/pieces.rb ADDED
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ PIECES = %i[I J L O S T Z].freeze
4
+
5
+ Face = Enum[:↑, :→, :↓, :←]
6
+
7
+ BLUEPRINT = {
8
+ I: [
9
+ [[0, 0], [0, -1], [0, 1], [0, 2]],
10
+ [[0, 0], [-1, 0], [1, 0], [2, 0]],
11
+ [[0, 0], [0, -1], [0, -2], [0, 1]],
12
+ [[0, 0], [-1, 0], [-2, 0], [1, 0]],
13
+ ],
14
+ J: [
15
+ [[0, 0], [0, -1], [-1, -1], [0, 1]],
16
+ [[0, 0], [-1, 0], [-1, 1], [1, 0]],
17
+ [[0, 0], [0, 1], [1, 1], [0, -1]],
18
+ [[0, 0], [1, 0], [1, -1], [-1, 0]],
19
+ ],
20
+ L: [
21
+ [[0, 0], [0, -1], [-1, 1], [0, 1]],
22
+ [[0, 0], [-1, 0], [1, 0], [1, 1]],
23
+ [[0, 0], [0, -1], [0, 1], [1, -1]],
24
+ [[0, 0], [-1, 0], [-1, -1], [1, 0]],
25
+ ],
26
+ O: [
27
+ [[0, 0], [-1, 1], [-1, 0], [0, 1]],
28
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
29
+ [[0, 0], [0, -1], [1, -1], [1, 0]],
30
+ [[0, 0], [-1, 0], [0, -1], [-1, -1]],
31
+ ],
32
+ S: [
33
+ [[0, 0], [-1, 1], [0, -1], [-1, 0]],
34
+ [[0, 0], [-1, 0], [0, 1], [1, 1]],
35
+ [[0, 0], [0, 1], [1, -1], [1, 0]],
36
+ [[0, 0], [0, -1], [-1, -1], [1, 0]],
37
+ ],
38
+ T: [
39
+ [[0, 0], [0, -1], [-1, 0], [0, 1]],
40
+ [[0, 0], [-1, 0], [0, 1], [1, 0]],
41
+ [[0, 0], [0, -1], [0, 1], [1, 0]],
42
+ [[0, 0], [0, -1], [-1, 0], [1, 0]],
43
+ ],
44
+ Z: [
45
+ [[0, 0], [-1, 0], [-1, -1], [0, 1]],
46
+ [[0, 0], [-1, 1], [0, 1], [1, 0]],
47
+ [[0, 0], [0, -1], [1, 0], [1, 1]],
48
+ [[0, 0], [0, -1], [-1, 0], [1, -1]],
49
+ ],
50
+ }.freeze
51
+
52
+ OFFSET = {
53
+ JLSTZ: {
54
+ [Face.↑, Face.→] => [[0, 0], [0, -1], [1, -1], [-2, 0], [-2, -1]],
55
+ [Face.→, Face.↑] => [[0, 0], [0, 1], [-1, 1], [2, 0], [2, 1]],
56
+ [Face.→, Face.↓] => [[0, 0], [0, 1], [-1, 1], [2, 0], [2, 1]],
57
+ [Face.↓, Face.→] => [[0, 0], [0, -1], [1, -1], [-2, 0], [-2, -1]],
58
+ [Face.↓, Face.←] => [[0, 0], [0, 1], [1, 1], [-2, 0], [-2, 1]],
59
+ [Face.←, Face.↓] => [[0, 0], [0, -1], [-1, -1], [2, 0], [2, -1]],
60
+ [Face.←, Face.↑] => [[0, 0], [0, -1], [-1, -1], [2, 0], [2, -1]],
61
+ [Face.↑, Face.←] => [[0, 0], [0, 1], [1, 1], [-2, 0], [-2, 1]],
62
+ },
63
+ I: {
64
+ [Face.↑, Face.→] => [[0, 0], [0, -2], [0, 1], [-1, -2], [2, 1]],
65
+ [Face.→, Face.↑] => [[0, 0], [0, 2], [0, -1], [1, 2], [-2, -1]],
66
+ [Face.→, Face.↓] => [[0, 0], [0, -1], [0, 2], [2, -1], [-1, 2]],
67
+ [Face.↓, Face.→] => [[0, 0], [0, 1], [0, -2], [-2, 1], [1, -2]],
68
+ [Face.↓, Face.←] => [[0, 0], [0, 2], [0, -1], [1, 2], [-2, -1]],
69
+ [Face.←, Face.↓] => [[0, 0], [0, -2], [0, 1], [-1, -2], [2, 1]],
70
+ [Face.←, Face.↑] => [[0, 0], [0, 1], [0, -2], [-2, 1], [1, -2]],
71
+ [Face.↑, Face.←] => [[0, 0], [0, -1], [0, 2], [2, -1], [-1, 2]],
72
+ },
73
+ }.freeze
74
+
75
+ Point = Struct.new(:y, :x) do
76
+ def +(other)
77
+ Point[y + other.y, x + other.x]
78
+ end
79
+
80
+ def -(other = nil)
81
+ return Point[-y, -x] if other.nil?
82
+ Point[y - other.y, x - other.x]
83
+ end
84
+ end
85
+
86
+ class Array
87
+ def to_p
88
+ Point[self[0], self[1]]
89
+ end
90
+ end
91
+
92
+ # A tetrimino piece
93
+ class BettyNiño
94
+ attr_reader :shape
95
+ attr_accessor :orientation, :center
96
+
97
+ def initialize(board, shape)
98
+ @board = board
99
+ @shape = shape
100
+ @orientation = Face.↑
101
+ @center = Point[20, 4]
102
+ end
103
+
104
+ # Move horizontally. Returns truthy if successful.
105
+ def move!(delta = -1)
106
+ niños.all? { |niño| @board.valid?(niño + Point[0, delta]) }
107
+ .tif { @center = @center + Point[0, delta] }
108
+ end
109
+
110
+ # Move down one row. Returns truthy if successful.
111
+ def slide!
112
+ niños.all? { |niño| @board.valid?(niño + Point[1, 0]) }
113
+ .tif { @center = @center + Point[1, 0] }
114
+ end
115
+
116
+ # Check if current position is valid
117
+ def valid?
118
+ niños.all? { |niño| @board.valid?(niño) }
119
+ end
120
+
121
+ # Rotate using Super Rotation System
122
+ def rotate!(clockwise: true)
123
+ current = @orientation
124
+ target = (current + (clockwise ? 1 : -1)) % 4
125
+
126
+ offsets = OFFSET[@shape == :I ? :I : :JLSTZ]
127
+ target_niños = niños(target)
128
+
129
+ offsets[[current, target]].any? do |offset|
130
+ offset_point = offset.to_p
131
+ target_niños.all? { |niño| @board.valid?(niño + offset_point) }
132
+ .tif do
133
+ @center = @center + offset_point
134
+ @orientation = target
135
+ end
136
+ end
137
+ end
138
+
139
+ # Get cell positions for given orientation
140
+ def niños(orientation = @orientation)
141
+ BLUEPRINT[@shape][orientation].map { |dy, dx| @center + Point[dy, dx] }
142
+ end
143
+
144
+ # Find ghost (landing preview) position
145
+ def ghost
146
+ original = @center
147
+ @center = Point[@center.y, @center.x]
148
+
149
+ @center = @center + Point[1, 0] while can_drop?
150
+
151
+ niños
152
+ ensure
153
+ @center = original
154
+ end
155
+
156
+ # Draw piece at its current position on the game board
157
+ def draw(oy, ox, win)
158
+ color = COLOR_PAIR[@shape]
159
+
160
+ niños.each do |niño|
161
+ next if niño.y <= SKYLINE_INDEX
162
+
163
+ win.setpos(oy + 1 + niño.y - SKYLINE_INDEX, ox + 19 + niño.x * 2)
164
+ win.attron(Curses.color_pair(color))
165
+ win.addstr("██")
166
+ win.attroff(Curses.color_pair(color))
167
+ end
168
+ end
169
+
170
+ # Draw piece in a preview box (hold/next)
171
+ def display(y, x)
172
+ color = COLOR_PAIR[@shape]
173
+ win = Curses.stdscr
174
+
175
+ win.attron(Curses.color_pair(color))
176
+
177
+ BLUEPRINT[@shape][Face.↑].each do |dy, dx|
178
+ win.setpos(y + 1 + dy, x + dx * 2)
179
+ win.addstr("██")
180
+ end
181
+
182
+ win.attroff(Curses.color_pair(color))
183
+ end
184
+
185
+ private
186
+
187
+ def can_drop?
188
+ niños.all? { |niño| @board.valid?(niño + Point[1, 0]) }
189
+ end
190
+ end
191
+
192
+ # O-piece doesn't rotate
193
+ class BettyNiñoO < BettyNiño
194
+ def rotate!(*)
195
+ # No-op for O piece
196
+ end
197
+ end
198
+
199
+ # Generate subclasses for each piece type
200
+ PIECES.each do |shape|
201
+ class_name = "BettyNiño#{shape}"
202
+ next if Object.const_defined?(class_name) # Skip O, already defined
203
+
204
+ klass = Class.new(BettyNiño) do
205
+ define_method(:initialize) do |board|
206
+ super(board, shape)
207
+ end
208
+ end
209
+
210
+ Object.const_set(class_name, klass)
211
+ end
212
+
213
+ # Special case: O needs its own initialize
214
+ class BettyNiñoO
215
+ def initialize(board)
216
+ super(board, :O)
217
+ end
218
+ end
data/lib/purse.rb ADDED
@@ -0,0 +1,33 @@
1
+ class Purse
2
+ attr_reader :bag
3
+
4
+ def initialize(board)
5
+ @board = board
6
+ @bag = []
7
+ @seen = Set.new
8
+ fill(7)
9
+ end
10
+
11
+ def pick
12
+ @bag.pop.tap { fill }
13
+ end
14
+
15
+ private
16
+
17
+ def get
18
+ @seen.clear if @seen.size == 7
19
+ piece = BettyNiño.subclasses.sample
20
+
21
+ if @seen.include?(piece)
22
+ get
23
+ else
24
+ @seen.add(piece)
25
+ piece.new(@board)
26
+ end
27
+ end
28
+
29
+ def fill(n = 1)
30
+ n.times { @bag << get }
31
+ end
32
+ end
33
+
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bettys
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - emanrdesu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: curses
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: optimist
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: emanlib
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: Bettys is a terminal-based Tetris clone written in Ruby with ncurses,
55
+ featuring four game modes (marathon, sprint, ultra, infinite), vim-style controls,
56
+ hold piece, ghost piece, SRS rotation, and more.
57
+ email:
58
+ - janitor@waifu.club
59
+ executables:
60
+ - bettys
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - asset/game.png
65
+ - asset/over.png
66
+ - bin/bettys
67
+ - lib/bettys.rb
68
+ - lib/board.rb
69
+ - lib/consts.rb
70
+ - lib/game.rb
71
+ - lib/pieces.rb
72
+ - lib/purse.rb
73
+ homepage: https://github.com/emanrdesu/bettys
74
+ licenses:
75
+ - MIT
76
+ metadata: {}
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '3.0'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.6.9
92
+ specification_version: 4
93
+ summary: A terminal-based Tetris clone
94
+ test_files: []