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.
- checksums.yaml +4 -4
- data/README.md +83 -30
- data/exe/upwords +1 -1
- data/lib/upwords.rb +8 -4
- data/lib/upwords/board.rb +4 -12
- data/lib/upwords/cursor.rb +4 -4
- data/lib/upwords/game.rb +70 -232
- data/lib/upwords/move.rb +45 -30
- data/lib/upwords/move_manager.rb +11 -3
- data/lib/upwords/player.rb +55 -48
- data/lib/upwords/shape.rb +11 -2
- data/lib/upwords/ui.rb +360 -0
- data/lib/upwords/version.rb +1 -1
- data/lib/upwords/word.rb +10 -9
- metadata +3 -3
- data/lib/upwords/graphics.rb +0 -149
data/lib/upwords/move.rb
CHANGED
@@ -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
|
-
#
|
10
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
-
|
42
|
-
raise IllegalMove, "#{bad_words.join(', ')} #{bad_words.size==1 ? 'is not a legal word' : 'are not legal words'}!"
|
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
|
-
#
|
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
|
-
#
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
data/lib/upwords/move_manager.rb
CHANGED
@@ -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
|
-
|
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
|
-
#
|
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)
|
data/lib/upwords/player.rb
CHANGED
@@ -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
|
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
|
-
#
|
79
|
-
#
|
80
|
+
# ---------------
|
81
|
+
# AI move methods
|
82
|
+
# ---------------
|
80
83
|
|
81
|
-
def
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
#
|
89
|
-
(0...
|
90
|
-
(0...
|
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
|
-
#
|
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
|
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
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
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
|
-
|
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 {|
|
115
|
+
letter_perms = Hash.new {|perms, sz| perms[sz] = letters.permutation(sz).to_a}
|
116
116
|
|
117
|
-
legal_move_shapes(board
|
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
|
-
|
125
|
-
|
126
|
-
|
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
|
-
|
135
|
-
|
136
|
-
move_arr =
|
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
|
|
data/lib/upwords/shape.rb
CHANGED
@@ -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)
|
data/lib/upwords/ui.rb
ADDED
@@ -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
|
+
|