upwords 0.1.1 → 0.2.1

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.
@@ -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
+