gemtris 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: 3ddbaa519dfd59a7069c33f8bf7f41ddf8f0e55e0f299bbc69ce14c359d0cd37
4
+ data.tar.gz: ffcb28edc3c99de861aa2d84429bbd35308a0dc34b76be4cd598f2cf040c477e
5
+ SHA512:
6
+ metadata.gz: 0e9a7db66141fab4057e0e17df027ef21a4ed54d65e79d8aaf813a35ab87f7c6bdee3c6c93add1158a44c576fbfacdeec96bd19300dc829d16873f59874b99b8
7
+ data.tar.gz: 1ce2aa2f38e817cbfb2ea557347e904975a938949a98a0cc5d1c2c99cfea28b094b5458f0b3c203a0182eab6d8c869c9d93f123f3129ef741e0d66428d65cc74
data/bin/gemtris ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'gemtris'
4
+
5
+ GemtrisGame.new.play
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: false
2
+
3
+ # Encapsulates the logic of a single square on the coordinate grid.
4
+ class Cell
5
+ CELL_VISUAL_SIZE = 3
6
+ EMPTY_CELL_VISUAL = '-'.freeze
7
+ FILLED_CELL_VISUAL = '[ ]'.freeze
8
+ CENTER_CELL_VISUAL = '[+]'.freeze
9
+ SHADOW_CELL_VISUAL = '( )'.freeze
10
+ SOLID_CELL_VISUAL = '[=]'.freeze
11
+
12
+ attr_reader :solid
13
+
14
+ def initialize
15
+ clear
16
+ end
17
+
18
+ def to_s
19
+ visual.center CELL_VISUAL_SIZE
20
+ end
21
+
22
+ def clear
23
+ self.solid = false
24
+ self.visual = EMPTY_CELL_VISUAL
25
+ end
26
+
27
+ def shape_fill
28
+ self.visual = FILLED_CELL_VISUAL
29
+ end
30
+
31
+ def shape_center_fill
32
+ self.visual = CENTER_CELL_VISUAL
33
+ end
34
+
35
+ def shade
36
+ self.visual = SHADOW_CELL_VISUAL
37
+ end
38
+
39
+ def solidify
40
+ self.solid = true
41
+ self.visual = SOLID_CELL_VISUAL
42
+ end
43
+
44
+ def solid?
45
+ @solid
46
+ end
47
+
48
+ private
49
+
50
+ attr_writer :solid
51
+
52
+ attr_accessor :visual
53
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: false
2
+
3
+ require_relative 'cell'
4
+
5
+ # Simulates the coordinate grid.
6
+ class Grid
7
+ GRID_BORDER_VERT = '||'.freeze
8
+ GRID_BORDER_HORIZ = '='.freeze
9
+ GRID_DEFAULT_WIDTH = 10
10
+ GRID_DEFAULT_HEIGHT = 20
11
+
12
+ attr_reader :height, :width, :board_visual_width
13
+
14
+ def initialize(width = GRID_DEFAULT_WIDTH, height = GRID_DEFAULT_HEIGHT)
15
+ @board_visual_width = (width * Cell::CELL_VISUAL_SIZE) +
16
+ (GRID_BORDER_VERT.size * 2)
17
+ @width = width
18
+ @height = height
19
+ @coordinate = init_coordinate
20
+ end
21
+
22
+ def to_s
23
+ vertical_visual = grid_vertical_visual
24
+ "#{vertical_visual}\n#{grid_playable_visual}\n#{vertical_visual}"
25
+ end
26
+
27
+ def draw_shape(shape)
28
+ draw_shadow(shape)
29
+
30
+ shape.filled_coords.each do |coord|
31
+ cell = find_cell(coord)
32
+ cell&.shape_fill
33
+ end
34
+
35
+ draw_shape_center(shape)
36
+ end
37
+
38
+ def draw_shadow(shape)
39
+ projected_landing_coords(shape).each do |shadow_coord|
40
+ cell = find_cell(shadow_coord)
41
+ cell&.shade
42
+ end
43
+ end
44
+
45
+ def draw_shape_center(shape)
46
+ center_coordinates = shape.absolute_center_coord
47
+ return unless center_coordinates
48
+
49
+ cell = find_cell(center_coordinates)
50
+ cell&.shape_center_fill
51
+ end
52
+
53
+ def undraw_shape(shape)
54
+ clear_coords = shape.filled_coords | projected_landing_coords(shape)
55
+ clear_coords.each do |coord|
56
+ cell = find_cell(coord)
57
+ cell&.clear
58
+ end
59
+ end
60
+
61
+ def solidify_shape(shape)
62
+ shape.filled_coords.each do |coord|
63
+ cell = find_cell(coord)
64
+ cell&.solidify
65
+ end
66
+ end
67
+
68
+ def row_full?(row_y_value)
69
+ row = coordinate[row_y_value]
70
+ row.values.all?(&:solid?)
71
+ end
72
+
73
+ def clear_row!(row_y_value)
74
+ row = coordinate[row_y_value]
75
+ row.each_value(&:clear)
76
+ end
77
+
78
+ def deep_clear_row!(row_y_value)
79
+ coordinate[row_y_value] = init_row_columns
80
+ end
81
+
82
+ def move_row_from_to(row_number, move_to)
83
+ coordinate[move_to] = coordinate[row_number]
84
+ coordinate[row_number] = init_row_columns
85
+ end
86
+
87
+ def shape_move_x?(shape, direction_sign = 1)
88
+ shape.filled_coords.none? do |adjacent_coord|
89
+ adjacent_coord[:x] += 1 * direction_sign
90
+ coord_out_bounds?(adjacent_coord) || !!find_cell(adjacent_coord)&.solid?
91
+ end
92
+ end
93
+
94
+ def shape_invalid_position?(shape)
95
+ shape_out_bounds?(shape) || shape_touching_solid?(shape)
96
+ end
97
+
98
+ def shape_touching_solid?(shape)
99
+ shape.filled_coords.any? { |coord| coord_touching_solid?(coord) }
100
+ end
101
+
102
+ def shape_out_bounds?(shape)
103
+ shape.filled_coords.any? { |coord| coord_out_bounds?(coord) }
104
+ end
105
+
106
+ def coord_touching_solid?(coord)
107
+ return true if coord_touching_bottom?(coord)
108
+ return true if find_cell(coord)&.solid?
109
+
110
+ coord_under = coord.dup
111
+ coord_under[:y] += 1
112
+
113
+ cell = find_cell(coord_under)
114
+ !!cell&.solid?
115
+ end
116
+
117
+ def coord_touching_bottom?(coord)
118
+ coord[:y] == height
119
+ end
120
+
121
+ def coord_out_bounds?(coord)
122
+ coord[:y] <= 0 || coord[:y] > height ||
123
+ coord[:x] <= 0 || coord[:x] > width
124
+ end
125
+
126
+ def [](row_y_value)
127
+ coordinate[row_y_value]
128
+ end
129
+
130
+ def []=(row_y_value, new_row)
131
+ coordinate[row_y_value] = new_row
132
+ end
133
+
134
+ private
135
+
136
+ def projected_landing_coords(shape)
137
+ original_position = shape.position.dup
138
+ shape.position[:y] += 1 until shape_invalid_position?(shape)
139
+
140
+ landing_coords = shape.filled_coords
141
+ shape.position = original_position
142
+ landing_coords
143
+ end
144
+
145
+ def find_cell(coord)
146
+ return nil if coord_out_bounds?(coord)
147
+
148
+ coordinate[coord[:y]][coord[:x]]
149
+ end
150
+
151
+ def grid_vertical_visual
152
+ GRID_BORDER_HORIZ * board_visual_width
153
+ end
154
+
155
+ def grid_playable_visual
156
+ coordinate.values.map do |row|
157
+ "#{GRID_BORDER_VERT}#{row.values.join}#{GRID_BORDER_VERT}"
158
+ end.join "\n"
159
+ end
160
+
161
+ def init_coordinate
162
+ coordinate = {}
163
+ (1..height).each do |y|
164
+ coordinate[y] = init_row_columns
165
+ end
166
+ coordinate
167
+ end
168
+
169
+ def init_row_columns
170
+ (1..width).to_h { |x_pos| [x_pos, Cell.new] }
171
+ end
172
+
173
+ attr_reader :coordinate
174
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: false
2
+
3
+ require_relative 'utils'
4
+
5
+ # Used to encapsulate player logic.
6
+ class Player
7
+ INVALID_ACTION = 'That is not a valid action!'.freeze
8
+
9
+ FALL_ACTION = :fall
10
+ MOVE_LEFT_ACTION = :move_left
11
+ MOVE_RIGHT_ACTION = :move_right
12
+ ROTATE_CLOCKWISE_ACTION = :rotate_clockwise
13
+ ROTATE_COUNTER_ACTION = :rotate_counter_clockwise
14
+ FREEFALL_ACTION = :freefall
15
+
16
+ PLAY_AGAIN_CHOICE = 'yes'.freeze
17
+
18
+ INPUTS_FOR_ACTIONS = {
19
+ ['<', ','] => MOVE_LEFT_ACTION,
20
+ ['>', '.'] => MOVE_RIGHT_ACTION,
21
+ ['d'] => ROTATE_COUNTER_ACTION,
22
+ ['a'] => ROTATE_CLOCKWISE_ACTION,
23
+ ['s'] => FREEFALL_ACTION
24
+ }.freeze
25
+
26
+ ACTION_PROMPT = format(
27
+ GemtrisText[:tetromino_control_prompt],
28
+ *INPUTS_FOR_ACTIONS.keys.flatten
29
+ )
30
+
31
+ def prompt_tetromino_action
32
+ puts ACTION_PROMPT
33
+ CLI.divide_screen
34
+
35
+ loop do
36
+ action = find_action(CLI.user_input)
37
+ return action if action
38
+
39
+ CLI.error(INVALID_ACTION)
40
+ end
41
+ end
42
+
43
+ def play_again?
44
+ answer = CLI.prompt GemtrisText[:play_again]
45
+ answered_yes?(answer)
46
+ end
47
+
48
+ private
49
+
50
+ def answered_yes?(answer)
51
+ PLAY_AGAIN_CHOICE.start_with?(answer) ||
52
+ answer.start_with?(PLAY_AGAIN_CHOICE)
53
+ end
54
+
55
+ def find_action(player_input)
56
+ return FALL_ACTION if player_input.empty?
57
+
58
+ INPUTS_FOR_ACTIONS.each_key do |input_list|
59
+ return INPUTS_FOR_ACTIONS[input_list] if input_list.include? player_input
60
+ end
61
+
62
+ nil
63
+ end
64
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: false
2
+
3
+ # I don't remember if grid is needed.
4
+ # This file might only need cell.rb
5
+ require_relative 'grid'
6
+
7
+ # An interface for general shape-related functionality.
8
+ module GemtrisShapes
9
+ ROTATE_CENTER = :rotate_center
10
+ LIMB_DATA = :limb_data
11
+
12
+ FILLED_CELL_PATTERN = 'N'.freeze
13
+ EMPTY_CELL_PATTERN = ' '.freeze
14
+
15
+ # Golomb names taken from
16
+ # "https://quuxplusone.github.io/blog/2025/04/19/tetromino-names/"
17
+ #
18
+ # :rotate_center is the coordinates of the
19
+ # limb that the shape rotates around. if this is not provided
20
+ # it is assumed that the shape cannot rotate
21
+
22
+ SHAPES =
23
+ [
24
+ # Square tetromino
25
+ { LIMB_DATA => "NN\nNN" },
26
+
27
+ # T-tetromino
28
+ { LIMB_DATA => " N\nNNN", ROTATE_CENTER => { y: 1, x: 1 } },
29
+
30
+ # Straight tetromino
31
+ { LIMB_DATA => 'NNNN', ROTATE_CENTER => { y: 0, x: 0 } },
32
+
33
+ # Skrew tetromino green
34
+ { LIMB_DATA => " NN\nNN", ROTATE_CENTER => { y: 1, x: 1 } },
35
+
36
+ # Skrew tetromino red
37
+ { LIMB_DATA => "NN\n NN", ROTATE_CENTER => { y: 1, x: 1 } },
38
+
39
+ # L-tetromino blue
40
+ { LIMB_DATA => "N\nNNN", ROTATE_CENTER => { y: 1, x: 0 } },
41
+
42
+ # L-tetromino orange
43
+ { LIMB_DATA => " N\nNNN", ROTATE_CENTER => { y: 1, x: 2 } }
44
+ ].freeze
45
+
46
+ def self.all_shapes
47
+ SHAPES.map { |shape_data| Shape.new(shape_data) }
48
+ end
49
+
50
+ def self.random_shape
51
+ Shape.new(SHAPES.sample)
52
+ end
53
+ end
54
+
55
+ # This is used to simulate physical shapes in the game in conjunction with the Grid class.
56
+ class Shape
57
+ FILLED_CELL_PATTERN = GemtrisShapes::FILLED_CELL_PATTERN
58
+ EMPTY_CELL_PATTERN = GemtrisShapes::EMPTY_CELL_PATTERN
59
+ EMPTY_SPACE_VISUAL = ' ' * Cell::FILLED_CELL_VISUAL.size
60
+
61
+ CLOCKWISE_SIGN = 1
62
+ COUNTER_CLOCKWISE_SIGN = -1
63
+
64
+ VISUAL_REGEX = /#{FILLED_CELL_PATTERN}|#{EMPTY_CELL_PATTERN}/
65
+ VISUAL_HASH = {
66
+ FILLED_CELL_PATTERN => Cell::FILLED_CELL_VISUAL,
67
+ EMPTY_CELL_PATTERN => EMPTY_SPACE_VISUAL
68
+ }.freeze
69
+
70
+ DEFAULT_POSITION = { y: 1, x: 1 }.freeze
71
+
72
+ attr_reader :visual, :limbs, :center_limb
73
+ attr_accessor :position
74
+
75
+ def initialize(shape_data)
76
+ limb_data = shape_data[GemtrisShapes::LIMB_DATA]
77
+
78
+ @visual = create_visual(limb_data)
79
+ @limbs = translate_string_to_limbs(limb_data)
80
+ @position = DEFAULT_POSITION.dup
81
+ @center_limb = find_center_limb shape_data[GemtrisShapes::ROTATE_CENTER]
82
+ end
83
+
84
+ def filled_coords
85
+ limbs.map { |limb| limb_absolute_coord(limb) }
86
+ end
87
+
88
+ def filled_x_coords
89
+ filled_coords.each_with_object(Set.new) { |coord, set| set << coord[:x] }
90
+ end
91
+
92
+ def rotate(rotation_direction = CLOCKWISE_SIGN)
93
+ return unless can_rotate?
94
+
95
+ offset_position_by_rotation(rotation_direction)
96
+ limbs.each { |limb| rotate_limb!(limb, rotation_direction) }
97
+ end
98
+
99
+ def absolute_center_coord
100
+ return nil unless can_rotate?
101
+
102
+ limb_absolute_coord(center_limb)
103
+ end
104
+
105
+ alias to_s visual
106
+
107
+ private
108
+
109
+ def can_rotate?
110
+ !!center_limb
111
+ end
112
+
113
+ def offset_position_by_rotation(rotation_direction = CLOCKWISE_SIGN)
114
+ rotated_center = limb_rotated(center_limb, rotation_direction)
115
+
116
+ position[:x] -= rotated_center[:x] - center_limb[:x]
117
+ position[:y] -= rotated_center[:y] - center_limb[:y]
118
+ end
119
+
120
+ def rotate_limb!(limb, rotation_direction = CLOCKWISE_SIGN)
121
+ rotated_coord = limb_rotated(limb, rotation_direction)
122
+ limb.merge!(rotated_coord)
123
+ end
124
+
125
+ def limb_rotated(limb, rotation_direction = CLOCKWISE_SIGN)
126
+ { y: limb[:x] * -rotation_direction, x: limb[:y] * rotation_direction }
127
+ end
128
+
129
+ def limb_absolute_coord(limb)
130
+ {
131
+ y: position[:y] + limb[:y],
132
+ x: position[:x] + limb[:x]
133
+ }
134
+ end
135
+
136
+ def create_visual(shape_string)
137
+ shape_string.gsub(VISUAL_REGEX, VISUAL_HASH)
138
+ end
139
+
140
+ def find_center_limb(rotation_limb)
141
+ limbs.find { |limb| limb == rotation_limb }
142
+ end
143
+
144
+ def translate_string_to_limbs(shape_string)
145
+ data = []
146
+ shape_string.split(/$/).each_with_index do |row, y_coord|
147
+ clean_row = row.delete "\n"
148
+ data.concat row_shape_x_coords(y_coord, clean_row)
149
+ end
150
+
151
+ data
152
+ end
153
+
154
+ # rubocop:disable Metrics/MethodLength
155
+ def row_shape_x_coords(y_coord, row_shape_string)
156
+ current_coord = 0
157
+ last_matched_index = 0
158
+ row_coords = []
159
+ (0...row_shape_string.size).each do |substr_index|
160
+ pattern = row_shape_string[last_matched_index..substr_index]
161
+ next unless [EMPTY_CELL_PATTERN, FILLED_CELL_PATTERN].include? pattern
162
+
163
+ row_coords << { y: y_coord, x: current_coord } if cell_pattern?(pattern)
164
+ current_coord += 1
165
+ last_matched_index = substr_index.next
166
+ end
167
+ row_coords
168
+ end
169
+ # rubocop:enable Metrics/MethodLength
170
+
171
+ def cell_pattern?(pattern)
172
+ pattern == FILLED_CELL_PATTERN
173
+ end
174
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: false
2
+
3
+ require 'psych'
4
+
5
+ # Comand-Line Utilities
6
+ module CLI
7
+ DEFAULT_PROMPT = 'Please enter text:'.freeze
8
+ DEFAULT_ERROR = 'Unknown error!'.freeze
9
+ SCREEN_DIVIDER = "\n------------------------\n\n".freeze
10
+
11
+ def self.divide_screen
12
+ print SCREEN_DIVIDER
13
+ end
14
+
15
+ def self.clear_screen
16
+ system('clear') || system('cls') || divide_screen
17
+ end
18
+
19
+ def self.user_input
20
+ print '> '
21
+ gets.chomp.strip.downcase
22
+ end
23
+
24
+ def self.prompt(message = DEFAULT_PROMPT)
25
+ puts message
26
+ divide_screen
27
+ user_input
28
+ end
29
+
30
+ def self.error(message = DEFAULT_ERROR)
31
+ divide_screen
32
+ puts "[ERROR]: #{message}"
33
+ divide_screen
34
+ end
35
+ end
36
+
37
+ # Provides an interface for developers to access the related YAML texts.
38
+ module GemtrisText
39
+ # assuming yml/gemtris and we're in lib/gemtris/
40
+ TEXTS = Psych.load_file("#{__dir__}/../../yml/gemtris.yml") # Psych.load(IO.read("#{__dir__}/../../yml/gemtris.yml"))
41
+
42
+ def self.[](key)
43
+ TEXTS[key.to_s]
44
+ end
45
+ end
data/lib/gemtris.rb ADDED
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: false
2
+
3
+ # Load all dependencies.
4
+ Dir["#{__dir__}/gemtris/*.rb"].each { |path| require path }
5
+
6
+ # The orchestator of gameplay.
7
+ class GemtrisGame
8
+ TITLE_PLATE = '] GEMTRIS ['.freeze
9
+ TITLE_DECOR = '-'.freeze
10
+
11
+ TUTORIAL_ANSWER = 'tutorial'.freeze
12
+ START_ANSWER = 'start'.freeze
13
+ INVALID_CHOICE = 'Invalid answer!'.freeze
14
+
15
+ ACTION_TIME_STEP = :step
16
+ ACTION_DONT_STEP = :dont_step
17
+ ACTION_KILL_TETROMINO = :kill
18
+
19
+ FAIL_TETROMINO_SPAWN = :failed_spawn
20
+ SUCESSFUL_TETROMINO_SPAWN = :spawned_tetromino
21
+
22
+ TIME_AFTER_ACTION = 0.1
23
+ CELL_FALL_TIME = 0.025
24
+ TETROMINO_FREEFALL_WAIT = 0.025
25
+ TETROMINO_DEATH_WAIT = 0.3
26
+
27
+ LEFT_DIRECTION = -1
28
+ RIGHT_DIRECTION = 1
29
+
30
+ AFTER_TUTORIAL_TEXT = GemtrisText[:after_tutorial] % START_ANSWER
31
+
32
+ LOSE_TEXT = GemtrisText[:lose]
33
+
34
+ TUTORIAL_TEXT = format(
35
+ GemtrisText[:tutorial],
36
+ Cell::EMPTY_CELL_VISUAL,
37
+ Cell::FILLED_CELL_VISUAL,
38
+ Cell::CENTER_CELL_VISUAL,
39
+ Cell::SHADOW_CELL_VISUAL,
40
+ Cell::SOLID_CELL_VISUAL
41
+ )
42
+
43
+ WELCOME_TEXT = format(
44
+ GemtrisText[:welcome],
45
+ TUTORIAL_ANSWER,
46
+ START_ANSWER
47
+ )
48
+
49
+ def play
50
+ intro_sequence
51
+ game_loop
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :grid, :player
57
+ attr_accessor :shape, :desired_spawn_x
58
+
59
+ def reset_game
60
+ @grid = Grid.new
61
+ @shape = nil
62
+ @player = Player.new
63
+ @desired_spawn_x = grid.width / 2
64
+ end
65
+
66
+ def game_loop
67
+ loop do
68
+ game_sequence
69
+ break unless player.play_again?
70
+ end
71
+ end
72
+
73
+ def intro_sequence
74
+ CLI.clear_screen
75
+ start_or_tutorial
76
+ end
77
+
78
+ def start_or_tutorial
79
+ puts WELCOME_TEXT
80
+ CLI.divide_screen
81
+
82
+ loop do
83
+ answer = CLI.user_input
84
+
85
+ tutorial_answer_loop if answer == TUTORIAL_ANSWER
86
+ break if [TUTORIAL_ANSWER, START_ANSWER].include? answer
87
+
88
+ CLI.error INVALID_CHOICE
89
+ end
90
+ end
91
+
92
+ def tutorial_answer_loop
93
+ CLI.clear_screen
94
+ puts TUTORIAL_TEXT
95
+ CLI.divide_screen
96
+ loop do
97
+ answer = CLI.prompt(AFTER_TUTORIAL_TEXT)
98
+ break if answer == START_ANSWER
99
+
100
+ CLI.error INVALID_CHOICE
101
+ end
102
+ end
103
+
104
+ def game_sequence
105
+ CLI.clear_screen
106
+ reset_game
107
+ gemtris_match_loop
108
+ display_match_loss
109
+ end
110
+
111
+ def gemtris_match_loop
112
+ loop do
113
+ spawn_result = spawn_fitting_tetromino
114
+ break if spawn_result == FAIL_TETROMINO_SPAWN
115
+
116
+ tetromino_control_loop
117
+ tetromino_die
118
+ clear_full_rows
119
+ end
120
+
121
+ tetromino_die
122
+ display_grid
123
+ end
124
+
125
+ def spawn_fitting_tetromino
126
+ randomized_shapes = GemtrisShapes.all_shapes.shuffle!
127
+
128
+ randomized_shapes.each do |random_shape|
129
+ self.shape = random_shape
130
+ spawn_result = set_shape_spawn
131
+
132
+ return spawn_result if spawn_result == SUCESSFUL_TETROMINO_SPAWN
133
+ end
134
+
135
+ FAIL_TETROMINO_SPAWN
136
+ end
137
+
138
+ def set_shape_spawn
139
+ desired_spawns.each do |x_pos|
140
+ shape.position[:x] = x_pos
141
+
142
+ return SUCESSFUL_TETROMINO_SPAWN unless grid.shape_invalid_position?(shape)
143
+ end
144
+
145
+ shape.position[:x] = desired_spawns.first
146
+ FAIL_TETROMINO_SPAWN
147
+ end
148
+
149
+ def desired_spawns
150
+ sort_distance_to_desired = proc { |x_pos| (desired_spawn_x - x_pos).abs }
151
+ (1..grid.width).to_a.shuffle.sort_by(&sort_distance_to_desired)
152
+ end
153
+
154
+ def tetromino_control_loop
155
+ loop do
156
+ draw_shape_and_display_grid
157
+
158
+ sleep TIME_AFTER_ACTION
159
+ action_result = player_action_loop
160
+
161
+ break if action_result == ACTION_KILL_TETROMINO
162
+
163
+ tetromino_fall_step
164
+
165
+ break if tetromino_stop_control?
166
+ end
167
+ end
168
+
169
+ def tetromino_stop_control?
170
+ grid.shape_touching_solid? shape
171
+ end
172
+
173
+ def tetromino_die
174
+ grid.solidify_shape shape
175
+ display_grid
176
+ self.desired_spawn_x = shape.position[:x]
177
+ sleep TETROMINO_DEATH_WAIT
178
+ end
179
+
180
+ def tetromino_fall_step
181
+ draw_shape_and_display_grid
182
+ sleep TIME_AFTER_ACTION
183
+ tetromino_descend
184
+ end
185
+
186
+ def player_action_loop
187
+ loop do
188
+ player_action = player.prompt_tetromino_action
189
+ action_result = perform_player_action(player_action)
190
+
191
+ break action_result unless action_result == ACTION_DONT_STEP
192
+
193
+ draw_shape_and_display_grid
194
+ end
195
+ end
196
+
197
+ def perform_player_action(player_action)
198
+ case player_action
199
+ when Player::FALL_ACTION then ACTION_TIME_STEP
200
+ when Player::MOVE_LEFT_ACTION then tetromino_move_left
201
+ when Player::MOVE_RIGHT_ACTION then tetromino_move_right
202
+ when Player::FREEFALL_ACTION then tetromino_freefall
203
+ when Player::ROTATE_CLOCKWISE_ACTION
204
+ rotate_tetromino Shape::CLOCKWISE_SIGN
205
+ when Player::ROTATE_COUNTER_ACTION
206
+ rotate_tetromino Shape::COUNTER_CLOCKWISE_SIGN
207
+ end
208
+ end
209
+
210
+ def tetromino_move_left
211
+ tetromino_move_x LEFT_DIRECTION
212
+ end
213
+
214
+ def tetromino_move_right
215
+ tetromino_move_x RIGHT_DIRECTION
216
+ end
217
+
218
+ def tetromino_move_x(direction_sign = RIGHT_DIRECTION)
219
+ return ACTION_DONT_STEP unless grid.shape_move_x?(shape, direction_sign)
220
+
221
+ shape.position[:x] += 1 * direction_sign
222
+ ACTION_KILL_TETROMINO if tetromino_stop_control?
223
+ end
224
+
225
+ def tetromino_descend
226
+ shape.position[:y] += 1
227
+ end
228
+
229
+ def rotate_tetromino(rotation_sign = Shape::CLOCKWISE_SIGN)
230
+ shape.rotate(rotation_sign)
231
+ shape.rotate(-rotation_sign) if grid.shape_invalid_position?(shape)
232
+
233
+ ACTION_DONT_STEP
234
+ end
235
+
236
+ def tetromino_freefall
237
+ until tetromino_stop_control?
238
+ tetromino_descend
239
+ draw_shape_and_display_grid
240
+ sleep TETROMINO_FREEFALL_WAIT
241
+ end
242
+ ACTION_KILL_TETROMINO
243
+ end
244
+
245
+ def clear_full_rows
246
+ lowest_empty_row = nil
247
+ (1..grid.height).reverse_each do |row_number|
248
+ if grid.row_full?(row_number)
249
+ clear_row!(row_number)
250
+ lowest_empty_row ||= row_number
251
+ elsif lowest_empty_row
252
+ row_fall_to!(row_number, lowest_empty_row)
253
+ lowest_empty_row -= 1
254
+ end
255
+ end
256
+ end
257
+
258
+ def row_fall_to!(from_row, to_row)
259
+ row = grid[from_row]
260
+ return if no_solid_cells_in_row?(row)
261
+
262
+ empty_row = grid[to_row]
263
+
264
+ (1..grid.width).each do |x_pos|
265
+ next unless row[x_pos].solid?
266
+
267
+ empty_row[x_pos] = row[x_pos]
268
+ row[x_pos] = Cell.new
269
+ display_grid
270
+ sleep CELL_FALL_TIME
271
+ end
272
+ end
273
+
274
+ def clear_row!(row_y)
275
+ (1..grid.width).each do |x_pos|
276
+ cell = grid[row_y][x_pos]
277
+ next unless cell.solid?
278
+
279
+ cell.clear
280
+ display_grid
281
+ sleep CELL_FALL_TIME
282
+ end
283
+ end
284
+
285
+ def no_solid_cells_in_row?(row)
286
+ row.values.none?(&:solid?)
287
+ end
288
+
289
+ def display_match_loss
290
+ puts LOSE_TEXT
291
+ CLI.divide_screen
292
+ end
293
+
294
+ def display_grid
295
+ grid_visual_size = grid.board_visual_width
296
+ centered_title = TITLE_PLATE.center(grid_visual_size, TITLE_DECOR)
297
+
298
+ CLI.clear_screen
299
+ puts "#{centered_title}\n\n"
300
+ puts grid
301
+ puts "\n#{TITLE_DECOR * grid_visual_size}"
302
+ puts
303
+ end
304
+
305
+ def draw_shape_and_display_grid
306
+ grid.draw_shape(shape)
307
+ display_grid
308
+ grid.undraw_shape(shape)
309
+ end
310
+ end
data/yml/gemtris.yml ADDED
@@ -0,0 +1,61 @@
1
+ welcome: |
2
+ WELCOME to [TETRIS]!
3
+
4
+ If you'd like a rundown on how to play please type, (%s).
5
+ Otherwise, type (%s)!
6
+
7
+ lose: |
8
+ OH NO!
9
+
10
+ You LOST! That was a great game!
11
+
12
+ tutorial: |
13
+ BASICS:
14
+
15
+ Tetris is a game played on a grid of squares or "cells".
16
+
17
+ Your goal is play for as long as you can, guiding the block shapes ("tetrominos"),
18
+ as they descend from the top of the grid.
19
+
20
+ A tetromino will solidify when it touches the bottom of the grid or another solid cell.
21
+ When this occurs, a new tetromino will appear for you to control.
22
+
23
+ CONTROLS:
24
+
25
+ Tetrominos can be moved left or right, and rotated in 90 degree increments.
26
+
27
+ You will have time to think between actions. The tetromino only falls one cell
28
+ after an action is performed.
29
+
30
+ Rotating does not cause the tetromino to fall. This is a safe action.
31
+
32
+ A tetromino "shadow" will appear that shows you where the tetromino is going to fall.
33
+
34
+ LOSE/WIN:
35
+
36
+ You lose when a tetromino has no more space to fall and touches the top of the grid.
37
+
38
+ Connecting an entire row of solid cells will cause the row is disintegrate. The solid rows
39
+ above will collapse to fill the space. This is your key to success!
40
+
41
+ An empty cell looks like this: %s
42
+ A tetromino limb looks like this: %s
43
+ The center of a tetromino looks like this: %s
44
+ A tetromino shadow looks like this: %s
45
+ A solid cell looks like this: %s
46
+
47
+
48
+ after_tutorial: |
49
+ To start, please type (%s)!
50
+
51
+ tetromino_control_prompt: |
52
+ To Move Left: %s or %s
53
+ To Move Right: %s or %s
54
+ To Rotate Clockwise: %s
55
+ To Rotate Counter-Clockwise: %s
56
+ To Freefall: %s
57
+
58
+ Or input nothing to fall once.
59
+
60
+ play_again: |
61
+ Would you like to play another game? (yes or .)
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemtris
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - TacoMilitary
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Using the keys on your keyboard, manipulate a falling shape within a
42
+ grid of cells in turn-based gameplay. Try to play for as long as possible without
43
+ touching the top of the grid.
44
+ email: tacobuddy18@outlook.com
45
+ executables:
46
+ - gemtris
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - bin/gemtris
51
+ - lib/gemtris.rb
52
+ - lib/gemtris/cell.rb
53
+ - lib/gemtris/grid.rb
54
+ - lib/gemtris/player.rb
55
+ - lib/gemtris/shapes.rb
56
+ - lib/gemtris/utils.rb
57
+ - yml/gemtris.yml
58
+ homepage: https://github.com/TacoMilitary/gemtris
59
+ licenses: []
60
+ metadata: {}
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.4.1
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: This is a simple command-line Ruby recreation of the game Tetris!
80
+ test_files: []