upwords 0.1.1 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|