upwords 0.1.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,59 +1,71 @@
1
+ # Encapsulates a possible move that a player could submit in a single turn
2
+
1
3
  module Upwords
2
4
  class Move
3
5
 
6
+ # Initialized with a list of 2D arrays, each containing a position (row, col) and a letter
4
7
  def initialize(tiles = [])
5
8
  @shape = Shape.new(tiles.map {|(row, col), letter| [row, col]})
6
9
  @move = tiles.to_h
7
10
  end
8
11
 
9
- # TODO: remove dict from word class
10
- # TODO: move score and new word methods to board class?
12
+ # Calculate value of move
13
+ # Most of the word score calculate logic is in the Word class. However, this method
14
+ # will also add 20 points if the player uses all of their letters in the move
11
15
  def score(board, player)
12
16
  new_words(board).reduce(player.rack_capacity == @move.size ? 20 : 0) do |total, word|
13
17
  total += word.score
14
18
  end
15
19
  end
16
20
 
17
- # TODO: Add the following legal move checks:
18
- # - Move is not a simple pluralization? (e.g. Cat -> Cats is NOT a legal move)
21
+ # Check if a move is legal
19
22
  def legal?(board, dict, raise_exception = false)
20
23
  legal_shape?(board, raise_exception) && legal_words?(board, dict, raise_exception)
21
24
  end
22
25
 
26
+ # Check if a move has a legal shape
23
27
  def legal_shape?(board, raise_exception = false)
24
28
  @shape.legal?(board, raise_exception)
25
- end
26
-
27
- def can_play_letters?(board, raise_exception = false)
28
- @move.all? do |(row, col), letter|
29
- board.can_play_letter?(letter, row, col, raise_exception)
30
- end
31
- end
32
-
29
+ end
30
+
31
+ # Check if all words that result from move are legal
33
32
  # TODO: Add the following legal move checks:
34
- # - Move is not a simple pluralization? (e.g. Cat -> Cats is NOT a legal move)
33
+ # TODO: - Move is not a simple pluralization? (e.g. Cat -> Cats is NOT a legal move)
35
34
  def legal_words?(board, dict, raise_exception = false)
36
35
 
37
36
  if can_play_letters?(board, raise_exception)
38
37
  bad_words = self.new_illegal_words(board, dict)
39
38
  if bad_words.empty?
40
39
  return true
41
- else
42
- raise IllegalMove, "#{bad_words.join(', ')} #{bad_words.size==1 ? 'is not a legal word' : 'are not legal words'}!" if raise_exception
40
+ elsif raise_exception
41
+ raise IllegalMove, "#{bad_words.join(', ')} #{bad_words.size==1 ? 'is not a legal word' : 'are not legal words'}!"
43
42
  end
44
43
  end
45
44
 
46
45
  return false
47
46
  end
48
47
 
48
+ # Check if entire move can be played on a board violating any board constraints, such as
49
+ # being out of bounds or exceeding the maximum stack height
50
+ def can_play_letters?(board, raise_exception = false)
51
+ @move.all? do |(row, col), letter|
52
+ board.can_play_letter?(letter, row, col, raise_exception)
53
+ end
54
+ end
55
+
56
+ # Check if a particular position (row, col) is covered by the move
49
57
  def position?(row, col)
50
58
  @move.key?([row, col])
51
59
  end
52
60
 
61
+ # Return the letter in position (row, col) of the move
53
62
  def [](row, col)
54
63
  @move[[row, col]]
55
64
  end
56
65
 
66
+ # Play move on board and return the board
67
+ # NOTE: this method mutates the boards!
68
+ # TODO: consider adding the 'can_play_letters?' check?
57
69
  def play(board)
58
70
  @move.reduce(board) do |b, (posn, letter)|
59
71
  b.play_letter(letter, *posn)
@@ -61,7 +73,8 @@ module Upwords
61
73
  end
62
74
  end
63
75
 
64
- # TODO: consider move main subroutine to Shape class?
76
+ # Remove a previous move from the board and return the board (throws an exception if the move does not exist on the board)
77
+ # NOTE: this method mutates the boards!
65
78
  def remove_from(board)
66
79
  if @move.any? {|(row, col), letter| board.top_letter(row, col) != letter}
67
80
  raise IllegalMove, "Move does not exist on board and therefore cannot be removed!"
@@ -73,23 +86,25 @@ module Upwords
73
86
  end
74
87
  end
75
88
 
76
- # TODO: handle exceptions when board cannot be updated with new move
77
- # TODO: move score and new word methods to board class?
78
- def new_words(board)
79
- # HACK: update board with new move
80
- words = (board.play_move(self).word_positions).select do |word_posns|
81
- word_posns.any? {|row, col| position?(row, col)}
82
-
83
- end.map do |word_posns|
84
- Word.new(word_posns, board)
89
+ # Return a list of new words that would result from playing this move on the board
90
+ def new_words(board, raise_exception = false)
91
+ if can_play_letters?(board, raise_exception)
92
+ # HACK: update board with new move
93
+ words = (board.play_move(self).word_positions).select do |word_posns|
94
+ word_posns.any? {|row, col| position?(row, col)}
95
+
96
+ end.map do |word_posns|
97
+ Word.new(word_posns, board)
98
+ end
99
+
100
+ # HACK: remove move from board
101
+ remove_from(board)
102
+
103
+ return words
85
104
  end
86
-
87
- # HACK: remove move from board
88
- remove_from(board)
89
-
90
- words
91
105
  end
92
106
 
107
+ # Return a list of new words that are not legal that would result from playing this move on the board
93
108
  def new_illegal_words(board, dict)
94
109
  new_words(board).reject {|word| dict.legal_word?(word.to_s)}
95
110
  end
@@ -11,15 +11,19 @@ module Upwords
11
11
  # --------------------------------
12
12
  # Player-Board Interaction Methods
13
13
  # --------------------------------
14
+
14
15
  def add(player, letter, row, col)
15
- # TODO: remove the need for @pending_move.map
16
- if (@pending_move.map {|m| m[0]}).include?([row, col])
16
+ if self.include?(row, col)
17
17
  raise IllegalMove, "You can't stack on a space more than once in a single turn!"
18
18
  elsif
19
19
  @pending_move << player.play_letter(@board, letter, row, col)
20
20
  end
21
21
  end
22
22
 
23
+ def include?(row, col)
24
+ @pending_move.map {|m| m[0]}.include?([row, col])
25
+ end
26
+
23
27
  def undo_last(player)
24
28
  if @pending_move.empty?
25
29
  raise IllegalMove, "No moves to undo!"
@@ -44,7 +48,11 @@ module Upwords
44
48
  end
45
49
  end
46
50
 
47
- # TODO: cache prev board in local variable...
51
+ # -------------------------------------
52
+ # Methods that require the game history
53
+ # -------------------------------------
54
+
55
+ # TODO: cache previous board in local variable...
48
56
  def pending_words
49
57
  prev_board = Board.build(@move_history, @board.size, @board.max_height)
50
58
  Move.new(@pending_move).new_words(prev_board)
@@ -1,7 +1,10 @@
1
+ # Encapsules a player
2
+ # Contains basic AI logic
3
+
1
4
  module Upwords
2
5
  class Player
3
6
 
4
- attr_reader :name, :cpu
7
+ attr_reader :name
5
8
  attr_accessor :score, :last_turn
6
9
 
7
10
  def initialize(name, rack_capacity=7, cpu=false)
@@ -12,10 +15,6 @@ module Upwords
12
15
  @cpu = cpu
13
16
  end
14
17
 
15
- def cpu?
16
- @cpu
17
- end
18
-
19
18
  def letters
20
19
  @rack.letters.dup
21
20
  end
@@ -39,6 +38,10 @@ module Upwords
39
38
  def take_letter(letter)
40
39
  @rack.add(letter)
41
40
  end
41
+
42
+ # -------------------------------
43
+ # Game object interaction methods
44
+ # -------------------------------
42
45
 
43
46
  def take_from(board, row, col)
44
47
  if board.stack_height(row, col) == 0
@@ -74,84 +77,88 @@ module Upwords
74
77
  end
75
78
  end
76
79
 
77
- # ----------------
78
- # CPU Move Methods
79
- # ----------------
80
+ # ---------------
81
+ # AI move methods
82
+ # ---------------
80
83
 
81
- def straight_moves(board)
82
- rows = board.num_rows
83
- cols = board.num_columns
84
-
85
- # Get single-position moves
84
+ def cpu?
85
+ @cpu
86
+ end
87
+
88
+ # Return a list of legal move shapes that player could make on board
89
+ def legal_move_shapes(board)
86
90
  one_space_moves = board.coordinates.map {|posn| [posn]}
87
91
 
88
- # Get board positions grouped by rows
89
- (0...rows).map do |row|
90
- (0...cols).map {|col| [row, col]}
92
+ # Collect board positions grouped by rows
93
+ (0...board.num_rows).map do |row|
94
+ (0...board.num_columns).map {|col| [row, col]}
91
95
 
92
- # Get horizontal multi-position moves
96
+ # Collect all positions of all possible horizontal multi-position moves that player could make
93
97
  end.flat_map do |posns|
94
98
  (2..(letters.size)).flat_map {|sz| posns.combination(sz).to_a}
95
99
 
96
- # Collect all possible straight moves
100
+ # Collect all positions of all possible vertical and horizontal moves that player could make
97
101
  end.reduce(one_space_moves) do |all_moves, move|
98
- all_moves << move << move.map {|posn| posn.rotate}
99
- end
100
- end
101
-
102
- # TODO: Strip out move filters and have the client provide them in a block
103
- def legal_move_shapes(board, &filter)
104
- straight_moves(board).select(&filter)
105
- end
106
-
107
- def standard_legal_shape_filter(board)
108
- proc do |move_arr|
109
- Shape.new(move_arr).legal?(board)
102
+ all_moves << move # Horizontal moves
103
+ all_moves << move.map {|posn| posn.rotate} # Vertical moves
104
+
105
+ # Filter out illegal move shapes
106
+ end.select do |move_posns|
107
+ Shape.new(move_posns).legal?(board)
110
108
  end
111
109
  end
112
110
 
113
- def legal_shape_letter_permutations(board, &filter)
111
+ # Return list of all possible letter permutations on legal move shapes that player could make on board
112
+ # Elements in the list will be in form of [(row, col), letter]
113
+ def legal_move_shapes_letter_permutations(board)
114
114
  # Cache result of letter permutation computation for each move size
115
- letter_perms = Hash.new {|ps, sz| ps[sz] = letters.permutation(sz).to_a}
115
+ letter_perms = Hash.new {|perms, sz| perms[sz] = letters.permutation(sz).to_a}
116
116
 
117
- legal_move_shapes(board, &filter).reduce([]) do |all_moves, move|
117
+ legal_move_shapes(board).reduce([]) do |all_moves, move|
118
118
  letter_perms[move.size].reduce(all_moves) do |move_perms, perm|
119
119
  move_perms << move.zip(perm)
120
120
  end
121
121
  end
122
122
  end
123
123
 
124
- def cpu_move(board, dict, sample_size = 1000, min_score = 0)
125
- all_possible_moves = (self.legal_shape_letter_permutations(board, &self.standard_legal_shape_filter(board)))
126
- all_possible_moves.shuffle!
127
-
124
+ # Execute a legal move based on a predefined strategy
125
+ #
126
+ # Basic strategy:
127
+ # - Find all legal move shapes and all possible letter permutations across those shapes (this computation is relatively quick)
128
+ # - Retun the highest score from permutation that do not produce in any illegal new words (this computation is slow...)
129
+ # - To speed up the above computation:
130
+ # + Only check a batch of permutations at a time (specified in 'batch_size' argument)
131
+ # + After each batch, terminate the subroutine if it finds a score that is at least as high as the given 'min_score'
132
+ # + Decrement the 'min_score' after each batch that does not terminate the subroutine to prevent endless searches
133
+ #
134
+ # TODO: refactor the the 'strategy' component out of this method, so different strategies can be swapped in and out
135
+ def cpu_move(board, dict, batch_size = 1000, min_score = 0)
136
+ possible_moves = self.legal_move_shapes_letter_permutations(board)
137
+ possible_moves.shuffle!
138
+
128
139
  top_score = 0
129
140
  top_score_move = nil
130
-
131
- # TODO: write test for this method
141
+
132
142
  while top_score_move.nil? || (top_score < min_score) do
133
-
134
- ([sample_size, all_possible_moves.size].min).times do
135
-
136
- move_arr = all_possible_moves.pop
143
+
144
+ # Check if next batch contains any legal moves and save the top score
145
+ ([batch_size, possible_moves.size].min).times do
146
+ move_arr = possible_moves.pop
137
147
  move = Move.new(move_arr)
138
148
 
139
149
  if move.legal_words?(board, dict)
140
-
141
150
  move_score = move.score(board, self)
142
-
143
151
  if move_score >= top_score
144
152
  top_score = move_score
145
153
  top_score_move = move_arr
146
154
  end
147
-
148
155
  end
149
156
  end
150
-
157
+
151
158
  # Decrement minimum required score after each cycle to help prevent long searches
152
159
  min_score = [(min_score - 1), 0].max
153
160
  end
154
-
161
+
155
162
  top_score_move
156
163
  end
157
164
 
@@ -1,3 +1,6 @@
1
+ # Encapsulates the shape of a possible move that a player could submit in a single turn
2
+ # Component of the Move class
3
+
1
4
  module Upwords
2
5
  class Shape
3
6
 
@@ -9,7 +12,7 @@ module Upwords
9
12
  end
10
13
 
11
14
  # Check if move creates a legal shape when added to a given board.
12
- # All checks assume that the move in question has not been played yet.
15
+ # NOTE: All checks assume that the move in question has not been played on the board yet.
13
16
  def legal?(board, raise_exception = false)
14
17
  if board.empty? && !in_middle_square?(board)
15
18
  raise IllegalMove, "You must play at least one letter in the middle 2x2 square!" if raise_exception
@@ -30,12 +33,16 @@ module Upwords
30
33
  return false
31
34
  end
32
35
 
36
+ # Check if move shape completely covers any existing word on the board
33
37
  def covering_moves?(board)
34
38
  (board.word_positions).any? do |word_posns|
35
39
  positions >= word_posns
36
40
  end
37
41
  end
38
42
 
43
+ # Check if all empty spaces in the rows and columns spanned by the move shape are covered by a previously-played tile on board
44
+ # For example, if the move shape = [1,1] [1,2] [1,4], then this method returns 'true' if the board has a tile at position [1,3]
45
+ # and 'false' if it does not.
39
46
  def gaps_covered_by?(board)
40
47
  row_range.all? do |row|
41
48
  col_range.all? do |col|
@@ -44,15 +51,17 @@ module Upwords
44
51
  end
45
52
  end
46
53
 
54
+ # Check if at least one position within the move shape is adjacent to or overlapping any tile on the board
47
55
  def touching?(board)
48
56
  @positions.any? do |row, col|
49
- # Are any positions overlapping or adjacent to a non-empty board space
50
57
  [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]].any? do |dr, dc|
51
58
  board.nonempty_space?(row + dr, col + dc)
52
59
  end
53
60
  end
54
61
  end
55
62
 
63
+ # Check if at least one position within the move shape is within the middle 2x2 square on the board
64
+ # This check is only performed at the beginning of the game
56
65
  def in_middle_square?(board)
57
66
  board.middle_square.any? do |posn|
58
67
  @positions.include?(posn)
@@ -0,0 +1,360 @@
1
+ module Upwords
2
+ class UI
3
+
4
+ def initialize(game, row_height = 1, col_width = 4)
5
+ # Game and drawing variables
6
+ @game = game
7
+ @rows = game.board.num_rows
8
+ @cols = game.board.num_columns
9
+ @row_height = row_height
10
+ @col_width = col_width
11
+ @rack_visible = false
12
+
13
+ # Configure Curses and initialize screen
14
+ Curses.noecho
15
+ Curses.curs_set(2) # Blinking cursor
16
+ Curses.init_screen
17
+ Curses.start_color
18
+
19
+ # Initialize colors
20
+ Curses.init_pair(RED, Curses::COLOR_RED, Curses::COLOR_BLACK) # Red on black background
21
+ Curses.init_pair(YELLOW, Curses::COLOR_YELLOW, Curses::COLOR_BLACK) # Yellow on black background
22
+
23
+ # Initialize main window and game loop
24
+ begin
25
+ @win = Curses.stdscr
26
+ @win.keypad=(true)
27
+
28
+ add_players
29
+ @game.all_refill_racks
30
+
31
+ @win.setpos(*letter_pos(*@game.cursor.pos))
32
+ draw_update_loop
33
+ ensure
34
+ Curses.close_screen
35
+ end
36
+ end
37
+
38
+ # ==================================
39
+ # Main methods: draw and input loops
40
+ # ==================================
41
+
42
+ def draw_update_loop
43
+ draw_grid
44
+ draw_controls
45
+
46
+ while @game.running? do
47
+ @rack_visible = false
48
+ draw_player_info
49
+ draw_message "#{@game.current_player.name}'s turn"
50
+
51
+ # CPU move subroutine
52
+ if @game.current_player.cpu?
53
+ draw_message "#{@game.current_player.name} is thinking..."
54
+ @game.cpu_move
55
+ draw_letters
56
+ draw_stack_heights
57
+ draw_player_info
58
+ # Read key inputs then update cursor and window
59
+ else
60
+ while read_key do
61
+ @win.setpos(*letter_pos(*@game.cursor.pos))
62
+ draw_letters
63
+ draw_stack_heights
64
+ draw_player_info
65
+ end
66
+ end
67
+
68
+ # Game over subroutine
69
+ if @game.game_over?
70
+ draw_player_info
71
+ get_game_result
72
+ end
73
+
74
+ end
75
+ end
76
+
77
+ # If read_key returns 'false', then current iteration of the input loop ends
78
+ def read_key
79
+ case (key = @win.getch)
80
+ when 'Q'
81
+ if draw_confirm("Are you sure you want to exit the game? (y/n)")
82
+ @game.exit_game
83
+ return false
84
+ end
85
+ when DELETE
86
+ @game.undo_last
87
+ draw_message(@game.standard_message) # TODO: factor this method
88
+ when Curses::Key::UP
89
+ @game.cursor.up
90
+ when Curses::Key::DOWN
91
+ @game.cursor.down
92
+ when Curses::Key::LEFT
93
+ @game.cursor.left
94
+ when Curses::Key::RIGHT
95
+ @game.cursor.right
96
+ when SPACE
97
+ @rack_visible = !@rack_visible
98
+ when ENTER
99
+ if draw_confirm("Are you sure you wanted to submit? (y/n)")
100
+ @game.submit_moves
101
+ return false
102
+ end
103
+ when '+'
104
+ draw_message("Pick a letter to swap")
105
+ letter = @win.getch
106
+ if letter =~ /[[:alpha:]]/ && draw_confirm("Swap '#{letter}' for a new letter? (y/n)")
107
+ @game.swap_letter(letter)
108
+ return false
109
+ else
110
+ draw_message("'#{letter}' is not a valid letter")
111
+ end
112
+ when '-'
113
+ if draw_confirm("Are you sure you wanted to skip your turn? (y/n)")
114
+ @game.skip_turn
115
+ return false
116
+ end
117
+ when /[[:alpha:]]/
118
+ @game.play_letter(key)
119
+ draw_message(@game.standard_message)
120
+ end
121
+
122
+ return true
123
+
124
+ rescue IllegalMove => exception
125
+ draw_confirm("#{exception.message} (press any key to continue...)")
126
+ return true
127
+ end
128
+
129
+ # =============================
130
+ # Subroutines in draw loop
131
+ # =============================
132
+
133
+ # Select the maximum number of players, then add players and select if they humans or computers
134
+ # TODO: refactor this method
135
+ def add_players
136
+ @win.setpos(0, 0)
137
+ Curses.echo
138
+ @win.keypad=(false)
139
+
140
+ num_players = 0
141
+
142
+ # Select how many players will be in the game
143
+ # TODO: Add a command-line flag to allow players to skip this step
144
+ until (1..@game.max_players).include?(num_players.to_i) do
145
+ @win.addstr("How many players will play? (1-#{@game.max_players})\n")
146
+ num_players = @win.getstr
147
+
148
+ @win.addstr("Invalid selection\n") if !(1..@game.max_players).include?(num_players.to_i)
149
+ @win.addstr("\n")
150
+
151
+ # Refresh screen if lines go beyond terminal window
152
+ clear_terminal if @win.cury >= @win.maxy - 1
153
+ end
154
+
155
+ # Name each player and choose if they are humans or computers
156
+ # TODO: Add a command-line flag to set this
157
+ (1..num_players.to_i).each do |idx|
158
+ @win.addstr("What is Player #{idx}'s name? (Press enter to submit...)\n")
159
+
160
+ name = @win.getstr
161
+ name = (name =~ /[[:alpha:]]/ ? name : sprintf('Player %d', idx))
162
+ @win.addstr("\nIs #{name} a computer? (y/n)\n")
163
+
164
+ cpu = @win.getstr
165
+ @game.add_player(name, rack_capacity=7, cpu.upcase == "Y")
166
+ @win.addstr("\n")
167
+
168
+ # Refresh screen if lines go beyond terminal window
169
+ clear_terminal if @win.cury >= @win.maxy - 1
170
+ end
171
+ ensure
172
+ Curses.noecho
173
+ @win.keypad=(true)
174
+ end
175
+
176
+ def get_game_result
177
+ draw_confirm("The game is over. Press any key to continue to see who won...")
178
+
179
+ top_score = @game.get_top_score
180
+ winners = @game.get_winners
181
+
182
+ if winners.size == 1
183
+ draw_confirm "And the winner is... #{winners.first} with #{top_score} points!"
184
+ else
185
+ draw_confirm "We have a tie! #{winners.join(', ')} all win with #{top_score} points!"
186
+ end
187
+
188
+ @game.exit_game
189
+ end
190
+
191
+ # =============================
192
+ # Draw individual game elements
193
+ # =============================
194
+
195
+ def draw_message(text)
196
+ write_str(*message_pos, text, clear_below=true)
197
+ end
198
+
199
+ def clear_message
200
+ draw_message("")
201
+ end
202
+
203
+ def draw_player_info
204
+ draw_wrapper do
205
+ py, px = player_info_pos
206
+
207
+ # Draw rack for current player only
208
+ write_str(py, px, "#{@game.current_player.name}'s letters:", clear_right=true)
209
+ write_str(py+1, px, "[#{@game.current_player.show_rack(masked=!@rack_visible)}]", clear_right=true)
210
+
211
+ @game.players.each_with_index do |p, i|
212
+ write_str(py+i+3, px, sprintf("%s %-13s %4d", p == @game.current_player ? "->" : " ", "#{p.name}:", p.score), clear_right=true)
213
+ end
214
+ end
215
+ end
216
+
217
+ # TODO: make confirmation options more clear
218
+ def draw_confirm(text)
219
+ draw_message("#{text}")
220
+ reply = (@win.getch.to_s).upcase == "Y"
221
+ clear_message
222
+ return reply
223
+ end
224
+
225
+ def draw_grid
226
+ draw_wrapper do
227
+ # create a list containing each line of the board string
228
+ divider = [nil, ["-" * @col_width] * @cols, nil].flatten.join("+")
229
+ spaces = [nil, [" " * @col_width] * @cols, nil].flatten.join("|")
230
+ lines = ([divider] * (@rows + 1)).zip([spaces] * @rows).flatten
231
+
232
+ # concatenate board lines and draw in a sub-window on the terminal
233
+ @win.setpos(0, 0)
234
+ @win.addstr(lines.join("\n"))
235
+ end
236
+ end
237
+
238
+ def draw_controls
239
+ draw_wrapper do
240
+ y, x = controls_info_pos
241
+ ["----------------------",
242
+ "| Controls |",
243
+ "----------------------",
244
+ "Show Letters [SPACE]",
245
+ "Undo Last Move [DEL]",
246
+ "Submit Move [ENTER]",
247
+ "Swap Letter [+]",
248
+ "Skip Turn [-]",
249
+ "Quit Game [SHIFT+Q]",
250
+ "Force Quit [CTRL+Z]" # TODO: technically this only works for unix shells...
251
+ ].each_with_index do |line, i|
252
+ @win.setpos(y+i, x)
253
+ @win.addstr(line)
254
+ end
255
+ end
256
+ end
257
+
258
+ def draw_letters
259
+ board = @game.board
260
+
261
+ draw_for_each_cell do |row, col|
262
+ @win.setpos(*letter_pos(row, col))
263
+
264
+ # HACK: removes yellow highlighting from 'Qu'
265
+ letter = "#{board.top_letter(row, col)} "[0..1]
266
+
267
+ if @game.pending_position?(row, col)
268
+ Curses.attron(Curses.color_pair(YELLOW)) { @win.addstr(letter) }
269
+ else
270
+ @win.addstr(letter)
271
+ end
272
+ end
273
+ end
274
+
275
+ def draw_stack_heights
276
+ board = @game.board
277
+
278
+ draw_for_each_cell do |row, col|
279
+ @win.setpos(*stack_height_pos(row, col))
280
+
281
+ case (height = board.stack_height(row, col))
282
+ when 0
283
+ @win.addstr("-")
284
+ when board.max_height
285
+ Curses.attron(Curses.color_pair(RED)) { @win.addstr(height.to_s) }
286
+ else
287
+ @win.addstr(height.to_s)
288
+ end
289
+ end
290
+ end
291
+
292
+ private
293
+
294
+ # ======================================
295
+ # Get positions of various game elements
296
+ # ======================================
297
+
298
+ def letter_pos(row, col)
299
+ [(row * (@row_height + 1)) + 1, (col * (@col_width + 1)) + 2] # TODO: magic nums are offsets
300
+ end
301
+
302
+ def stack_height_pos(row, col)
303
+ [(row * (@row_height + 1)) + 2, (col * (@col_width + 1)) + @col_width] # TODO: magic nums are offsets
304
+ end
305
+
306
+ def message_pos
307
+ [@rows * (@row_height + 1) + 2, 0] # TODO: magic nums are offsets
308
+ end
309
+
310
+ def player_info_pos
311
+ [1, @cols * (@col_width + 1) + 4] # TODO: magic_nums are offsets
312
+ end
313
+
314
+ def controls_info_pos
315
+ y, x = player_info_pos
316
+ return [y + (@rows * (@row_height + 1)) / 2, x] # TODO: magic_nums are offsets
317
+ end
318
+
319
+ # ======================
320
+ # Drawing helper methods
321
+ # ======================
322
+
323
+ # Execute draw operation in block and reset cursors and refresh afterwards
324
+ def draw_wrapper(&block)
325
+ cury, curx = @win.cury, @win.curx
326
+
327
+ yield block if block_given?
328
+
329
+ @win.setpos(cury, curx)
330
+ @win.refresh
331
+ end
332
+
333
+ def draw_for_each_cell(&block)
334
+ draw_wrapper do
335
+ (0...@rows).each do |row|
336
+ (0...@cols).each { |col| block.call(row, col)}
337
+ end
338
+ end
339
+ end
340
+
341
+ # Write text to position (y, x) of the terminal
342
+ # Optionally, delete all text to the right or all lines below before writing text
343
+ def write_str(y, x, text, clear_right = false, clear_below = false)
344
+ draw_wrapper do
345
+ @win.setpos(y, x)
346
+ draw_wrapper { @win.addstr(" " * (@win.maxx - @win.curx)) } if clear_right
347
+ draw_wrapper { (@win.maxy - @win.cury + 1).times { @win.deleteln } } if clear_below
348
+ draw_wrapper { @win.addstr(text) }
349
+ end
350
+ end
351
+
352
+ def clear_terminal
353
+ @win.clear
354
+ @win.refresh
355
+ @win.setpos(0, 0)
356
+ end
357
+
358
+ end
359
+ end
360
+