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 +7 -0
- data/asset/game.png +0 -0
- data/asset/over.png +0 -0
- data/bin/bettys +101 -0
- data/lib/bettys.rb +9 -0
- data/lib/board.rb +52 -0
- data/lib/consts.rb +92 -0
- data/lib/game.rb +355 -0
- data/lib/pieces.rb +218 -0
- data/lib/purse.rb +33 -0
- metadata +94 -0
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
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: []
|