upwords 0.1.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 +7 -0
- data/.gitignore +12 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +250 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/data/ospd.txt +79339 -0
- data/exe/upwords +5 -0
- data/lib/upwords.rb +48 -0
- data/lib/upwords/board.rb +131 -0
- data/lib/upwords/cursor.rb +39 -0
- data/lib/upwords/dictionary.rb +31 -0
- data/lib/upwords/game.rb +325 -0
- data/lib/upwords/graphics.rb +149 -0
- data/lib/upwords/letter_bank.rb +22 -0
- data/lib/upwords/letter_rack.rb +51 -0
- data/lib/upwords/move.rb +98 -0
- data/lib/upwords/move_manager.rb +64 -0
- data/lib/upwords/player.rb +159 -0
- data/lib/upwords/shape.rb +98 -0
- data/lib/upwords/version.rb +3 -0
- data/lib/upwords/word.rb +50 -0
- data/upwords.gemspec +28 -0
- metadata +158 -0
data/exe/upwords
ADDED
data/lib/upwords.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'curses'
|
3
|
+
|
4
|
+
require 'upwords/version'
|
5
|
+
|
6
|
+
require 'upwords/board'
|
7
|
+
require 'upwords/letter_bank'
|
8
|
+
require 'upwords/letter_rack'
|
9
|
+
require 'upwords/dictionary'
|
10
|
+
|
11
|
+
require 'upwords/shape'
|
12
|
+
require 'upwords/move'
|
13
|
+
require 'upwords/move_manager'
|
14
|
+
|
15
|
+
require 'upwords/cursor'
|
16
|
+
|
17
|
+
require 'upwords/word'
|
18
|
+
require 'upwords/player'
|
19
|
+
require 'upwords/graphics'
|
20
|
+
require 'upwords/game'
|
21
|
+
|
22
|
+
module Upwords
|
23
|
+
# Raised when player makes an illegal move
|
24
|
+
class IllegalMove < StandardError
|
25
|
+
end
|
26
|
+
|
27
|
+
# Letters available in 10 x 10 version of Upwords
|
28
|
+
ALL_LETTERS = {
|
29
|
+
8 => ["E"],
|
30
|
+
7 => ["A", "I", "O"],
|
31
|
+
6 => ["S"],
|
32
|
+
5 => ["D", "L", "M", "N", "R", "T", "U"],
|
33
|
+
4 => ["C"],
|
34
|
+
3 => ["B", "F", "G", "H", "P"],
|
35
|
+
2 => ["K", "W", "Y"],
|
36
|
+
1 => ["J", "Qu", "V", "X", "Z"]
|
37
|
+
}.flat_map {|count, letters| letters * count}
|
38
|
+
|
39
|
+
# Curses Key Constants
|
40
|
+
ESCAPE = 27
|
41
|
+
SPACE = ' '
|
42
|
+
DELETE = 127
|
43
|
+
ENTER = 10
|
44
|
+
|
45
|
+
# Official Scrabble Player Dictionary file
|
46
|
+
OSPD_FILE = File.join(File.dirname(__FILE__), "../data/ospd.txt")
|
47
|
+
|
48
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
module Upwords
|
2
|
+
class Board
|
3
|
+
|
4
|
+
attr_accessor :max_height, :size
|
5
|
+
|
6
|
+
# creates a 10 x 10 board
|
7
|
+
def initialize(size=10, max_height=5)
|
8
|
+
if !size.positive?
|
9
|
+
raise ArgumentError, "Board size must be greater than zero!"
|
10
|
+
else
|
11
|
+
@size = size
|
12
|
+
@max_height = max_height
|
13
|
+
@grid = Hash.new do |h, (row, col)|
|
14
|
+
if row < 0 || col < 0 || num_rows <= row || num_columns <= col
|
15
|
+
raise IllegalMove, "#{row}, #{col} is out of bounds!"
|
16
|
+
else
|
17
|
+
h[[row, col]] = [] # Initialize with empty array
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def empty?
|
24
|
+
@grid.empty? || @grid.each_key.all? {|k| @grid[k].empty?}
|
25
|
+
end
|
26
|
+
|
27
|
+
def nonempty_space?(row, col)
|
28
|
+
@grid.key?([row, col]) && stack_height(row, col) > 0
|
29
|
+
end
|
30
|
+
|
31
|
+
# maximum letters than can be stacked in one space
|
32
|
+
def min_word_length
|
33
|
+
2
|
34
|
+
end
|
35
|
+
|
36
|
+
def num_rows
|
37
|
+
@size
|
38
|
+
end
|
39
|
+
|
40
|
+
def num_columns
|
41
|
+
@size
|
42
|
+
end
|
43
|
+
|
44
|
+
# Defines a 2x2 square in the middle of the board (in the case of the 10 x 10 board)
|
45
|
+
# The top left corner of the square is the initial cursor position
|
46
|
+
# The square itself defines the region where at least one of the first letters must be placed
|
47
|
+
def middle_square
|
48
|
+
[1, 0].product([1, 0]).map do |r, c|
|
49
|
+
[(num_rows) / 2 - r, (num_columns) / 2 - c]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def stack_height(row, col)
|
54
|
+
@grid[[row, col]].size
|
55
|
+
end
|
56
|
+
|
57
|
+
def play_move(move)
|
58
|
+
move.play(self)
|
59
|
+
end
|
60
|
+
|
61
|
+
def undo_move(move)
|
62
|
+
move.remove_from(self)
|
63
|
+
end
|
64
|
+
|
65
|
+
def can_play_letter?(letter, row, col, raise_exception = false)
|
66
|
+
if stack_height(row, col) == max_height
|
67
|
+
raise IllegalMove, "You cannot stack any more letters on this space" if raise_exception
|
68
|
+
elsif top_letter(row, col) == letter
|
69
|
+
raise IllegalMove, "You cannot stack a letter on the same letter!" if raise_exception
|
70
|
+
else
|
71
|
+
return true
|
72
|
+
end
|
73
|
+
return false
|
74
|
+
end
|
75
|
+
|
76
|
+
def play_letter(letter, row, col)
|
77
|
+
if can_play_letter?(letter, row, col, raise_exception = true)
|
78
|
+
@grid[[row, col]] << letter
|
79
|
+
return [[row, col], letter] # Return position after successfully playing a move
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def remove_top_letter(row, col)
|
84
|
+
@grid[[row, col]].pop
|
85
|
+
end
|
86
|
+
|
87
|
+
# show top letter in board space
|
88
|
+
def top_letter(row, col)
|
89
|
+
get_letter(row, col, 1)
|
90
|
+
end
|
91
|
+
|
92
|
+
def get_letter(row, col, depth=1)
|
93
|
+
@grid[[row, col]][-depth]
|
94
|
+
end
|
95
|
+
|
96
|
+
def word_positions
|
97
|
+
row_word_posns + column_word_posns
|
98
|
+
end
|
99
|
+
|
100
|
+
def nonempty_spaces
|
101
|
+
coordinates.select {|row, col| nonempty_space?(row, col)}.to_set
|
102
|
+
end
|
103
|
+
|
104
|
+
def coordinates
|
105
|
+
(0...num_rows).to_a.product((0...num_columns).to_a)
|
106
|
+
end
|
107
|
+
|
108
|
+
def self.build(moves, size=10, max_height=5)
|
109
|
+
moves.reduce(Board.new(size, max_height)) do |board, move|
|
110
|
+
board.play_move(move)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
def collect_word_posns(&block)
|
117
|
+
SortedSet.new(nonempty_spaces).divide(&block).select do |w|
|
118
|
+
w.length >= min_word_length
|
119
|
+
end.to_set
|
120
|
+
end
|
121
|
+
|
122
|
+
def row_word_posns
|
123
|
+
collect_word_posns {|(r1,c1),(r2,c2)| (c1 - c2).abs == 1 && r1 == r2 }
|
124
|
+
end
|
125
|
+
|
126
|
+
def column_word_posns
|
127
|
+
collect_word_posns {|(r1,c1),(r2,c2)| (r1 - r2).abs == 1 && c1 == c2 }
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Upwords
|
2
|
+
class Cursor
|
3
|
+
attr_reader :x, :y
|
4
|
+
|
5
|
+
def initialize(max_y, max_x, init_y = 0, init_x = 0)
|
6
|
+
@max_y = max_y
|
7
|
+
@max_x = max_x
|
8
|
+
|
9
|
+
# TODO: Handle case where init_y, x are outside bounds
|
10
|
+
@y = init_y
|
11
|
+
@x = init_x
|
12
|
+
end
|
13
|
+
|
14
|
+
def up
|
15
|
+
move(-1, 0)
|
16
|
+
end
|
17
|
+
|
18
|
+
def down
|
19
|
+
move(1, 0)
|
20
|
+
end
|
21
|
+
|
22
|
+
def left
|
23
|
+
move(0, -1)
|
24
|
+
end
|
25
|
+
|
26
|
+
def right
|
27
|
+
move(0, 1)
|
28
|
+
end
|
29
|
+
|
30
|
+
def move(dy, dx)
|
31
|
+
@y = (y + dy) % @max_y
|
32
|
+
@x = (x + dx) % @max_x
|
33
|
+
end
|
34
|
+
|
35
|
+
def posn
|
36
|
+
[y, x]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Upwords
|
2
|
+
class Dictionary
|
3
|
+
|
4
|
+
def initialize(words = [])
|
5
|
+
@legal_words = Set.new(words.map {|w| w.upcase})
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.import(filepath)
|
9
|
+
dict = Dictionary.new
|
10
|
+
File.foreach(filepath) do |line|
|
11
|
+
dict << line.chomp
|
12
|
+
end
|
13
|
+
dict
|
14
|
+
end
|
15
|
+
|
16
|
+
def legal_word? word
|
17
|
+
@legal_words.member? (word.upcase)
|
18
|
+
end
|
19
|
+
|
20
|
+
def add_word word
|
21
|
+
@legal_words.add? (word.upcase)
|
22
|
+
end
|
23
|
+
|
24
|
+
alias_method :<<, :add_word
|
25
|
+
|
26
|
+
def remove_word word
|
27
|
+
@legal_words.delete? (word.upcase)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
data/lib/upwords/game.rb
ADDED
@@ -0,0 +1,325 @@
|
|
1
|
+
module Upwords
|
2
|
+
class Game
|
3
|
+
attr_reader :board, :cursor, :players
|
4
|
+
|
5
|
+
def initialize(display_on = true, max_players = 4)
|
6
|
+
@max_players = max_players
|
7
|
+
@display_on = display_on
|
8
|
+
@board = Board.new(10, 5)
|
9
|
+
@letter_bank = LetterBank.new(ALL_LETTERS.dup)
|
10
|
+
@cursor = Cursor.new(@board.num_rows,
|
11
|
+
@board.num_columns,
|
12
|
+
*@board.middle_square[0])
|
13
|
+
@dict = Dictionary.import(OSPD_FILE)
|
14
|
+
@moves = MoveManager.new(@board, @dict)
|
15
|
+
@players = []
|
16
|
+
@running = false
|
17
|
+
@submitted = false
|
18
|
+
end
|
19
|
+
|
20
|
+
# =========================================
|
21
|
+
# Player Methods
|
22
|
+
# =========================================
|
23
|
+
|
24
|
+
def current_player
|
25
|
+
@players.first
|
26
|
+
end
|
27
|
+
|
28
|
+
def max_players
|
29
|
+
@max_players
|
30
|
+
end
|
31
|
+
|
32
|
+
def player_count
|
33
|
+
player_count = @players.size
|
34
|
+
end
|
35
|
+
|
36
|
+
def add_player(name = nil, cpu = false)
|
37
|
+
if player_count >= max_players
|
38
|
+
raise StandardError, "No more players can join"
|
39
|
+
else
|
40
|
+
if name.nil? || name.size == 0
|
41
|
+
name = "Player #{player_count + 1}"
|
42
|
+
end
|
43
|
+
@players << Player.new(name, rack_capacity=7, cpu)
|
44
|
+
end
|
45
|
+
@players.each {|p| p.refill_rack(@letter_bank) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def add_players(player_names = nil)
|
49
|
+
print "\n"
|
50
|
+
num_players = 0
|
51
|
+
|
52
|
+
# Select how many players will be in the game
|
53
|
+
# TODO: Add a command-line flag to allow players to skip this step
|
54
|
+
until (1..@max_players).include?(num_players) do
|
55
|
+
print "How many players will play? (1-#{@max_players})\n"
|
56
|
+
num_players = gets.chomp.to_i
|
57
|
+
print "\n"
|
58
|
+
if !(1..@max_players).include?(num_players)
|
59
|
+
print "Invalid selection: #{num_players}\n\n"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Name each player and choose if they are humans or computers
|
64
|
+
# TODO: Add a command-line flag to set this
|
65
|
+
(1..num_players).each do |idx|
|
66
|
+
print "What is Player #{idx}'s name?\n"
|
67
|
+
name = gets.chomp
|
68
|
+
print "Is Player #{idx} or a computer? (y/n)\n"
|
69
|
+
cpu = gets.chomp
|
70
|
+
add_player(name, cpu.upcase == "Y")
|
71
|
+
print "\n"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# =========================================
|
76
|
+
# Graphics Methods
|
77
|
+
# =========================================
|
78
|
+
|
79
|
+
def init_window
|
80
|
+
Curses.noecho
|
81
|
+
Curses.curs_set(0)
|
82
|
+
Curses.init_screen
|
83
|
+
# Curses.start_color
|
84
|
+
|
85
|
+
@win = Graphics.new(self)
|
86
|
+
@win.keypad(true)
|
87
|
+
end
|
88
|
+
|
89
|
+
def display_on?
|
90
|
+
@display_on
|
91
|
+
end
|
92
|
+
|
93
|
+
def refresh_graphics
|
94
|
+
if display_on? && running?
|
95
|
+
@win.refresh
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def update_message msg
|
100
|
+
if display_on?
|
101
|
+
@win.message = msg
|
102
|
+
refresh_graphics
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def clear_message
|
107
|
+
update_message standard_message
|
108
|
+
end
|
109
|
+
|
110
|
+
def standard_message
|
111
|
+
"#{current_player.name}'s pending words: #{pending_result}"
|
112
|
+
end
|
113
|
+
|
114
|
+
def pending_result
|
115
|
+
new_words = @moves.pending_words
|
116
|
+
|
117
|
+
unless new_words.empty?
|
118
|
+
new_words.map do |w|
|
119
|
+
"#{w} (#{w.score})"
|
120
|
+
end.join(", ") + " (Total = #{@moves.pending_score(current_player)})"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# =========================================
|
125
|
+
# Game Loops & Non-Input Procedures
|
126
|
+
# =========================================
|
127
|
+
|
128
|
+
def running?
|
129
|
+
@running
|
130
|
+
end
|
131
|
+
|
132
|
+
def run
|
133
|
+
@running = true
|
134
|
+
|
135
|
+
# Add players
|
136
|
+
add_players
|
137
|
+
|
138
|
+
# Start graphics
|
139
|
+
init_window if @display_on
|
140
|
+
clear_message
|
141
|
+
|
142
|
+
# Start main loop
|
143
|
+
while running? do
|
144
|
+
begin
|
145
|
+
# ------ CPU MOVE --------
|
146
|
+
if current_player.cpu?
|
147
|
+
update_message "#{current_player.name} is thinking..."
|
148
|
+
cpu_move = current_player.cpu_move(@board, @dict, 50, 10)
|
149
|
+
|
150
|
+
if !cpu_move.nil?
|
151
|
+
cpu_move.each do |posn, letter|
|
152
|
+
@moves.add(current_player, letter, *posn)
|
153
|
+
end
|
154
|
+
submit_moves(need_confirm=false)
|
155
|
+
else
|
156
|
+
skip_turn(need_confirm=false)
|
157
|
+
end
|
158
|
+
else
|
159
|
+
read_input(@win.getch)
|
160
|
+
end
|
161
|
+
|
162
|
+
if @submitted
|
163
|
+
# TODO: remove magic string from last move message
|
164
|
+
if @players.all? {|p| p.last_turn == "skipped turn"} || @letter_bank.empty? && current_player.rack_empty?
|
165
|
+
game_over
|
166
|
+
@running = false
|
167
|
+
else
|
168
|
+
next_turn
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
refresh_graphics
|
173
|
+
|
174
|
+
rescue IllegalMove => exception
|
175
|
+
update_message "#{exception.message} (press any key to continue...)"
|
176
|
+
@win.getch if display_on?
|
177
|
+
clear_message
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
end
|
182
|
+
|
183
|
+
def read_input(key)
|
184
|
+
case key
|
185
|
+
when 'Q'
|
186
|
+
exit_game
|
187
|
+
when SPACE
|
188
|
+
toggle_rack_visibility
|
189
|
+
when DELETE
|
190
|
+
undo_moves
|
191
|
+
when ENTER
|
192
|
+
submit_moves
|
193
|
+
when Curses::KEY_UP
|
194
|
+
@cursor.up
|
195
|
+
when Curses::KEY_DOWN
|
196
|
+
@cursor.down
|
197
|
+
when Curses::KEY_LEFT
|
198
|
+
@cursor.left
|
199
|
+
when Curses::KEY_RIGHT
|
200
|
+
@cursor.right
|
201
|
+
when '+'
|
202
|
+
swap_letter
|
203
|
+
when '-'
|
204
|
+
skip_turn
|
205
|
+
when /[[:alpha:]]/
|
206
|
+
@moves.add(current_player, modify_letter_input(key), @cursor.y, @cursor.x)
|
207
|
+
clear_message
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def next_turn
|
212
|
+
@players.rotate!
|
213
|
+
@win.hide_rack if display_on?
|
214
|
+
clear_message
|
215
|
+
@submitted = false
|
216
|
+
end
|
217
|
+
|
218
|
+
# =========================================
|
219
|
+
# Methods Related to Key Inputs
|
220
|
+
# =========================================
|
221
|
+
|
222
|
+
# Capitalize letters, and convert 'Q' and 'q' to 'Qu'
|
223
|
+
def modify_letter_input(letter)
|
224
|
+
if letter =~ /[Qq]/
|
225
|
+
'Qu'
|
226
|
+
else
|
227
|
+
letter.capitalize
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def confirm_action?(question_text)
|
232
|
+
if display_on?
|
233
|
+
update_message "#{question_text} (y/n)"
|
234
|
+
inp = @win.getch
|
235
|
+
clear_message
|
236
|
+
inp == 'y' || inp == 'Y'
|
237
|
+
else
|
238
|
+
true # Never ask for confirm if the display_on is off
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# =========================================
|
243
|
+
# Game Procedures Bound to some Key Input
|
244
|
+
# =========================================
|
245
|
+
|
246
|
+
def undo_moves
|
247
|
+
@moves.undo_last(current_player)
|
248
|
+
clear_message
|
249
|
+
end
|
250
|
+
|
251
|
+
def submit_moves(need_confirm=true)
|
252
|
+
if !need_confirm || (confirm_action? "Are you sure you want to submit?")
|
253
|
+
@moves.submit(current_player)
|
254
|
+
current_player.refill_rack(@letter_bank)
|
255
|
+
@submitted = true
|
256
|
+
clear_message
|
257
|
+
|
258
|
+
# TODO: remove magic string from last move message
|
259
|
+
current_player.last_turn = "played word"
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
# TODO: Test this method...
|
264
|
+
def swap_letter(need_confirm=true)
|
265
|
+
update_message "Pick a letter to swap... "
|
266
|
+
letter = @win.getch
|
267
|
+
|
268
|
+
if letter =~ /[[:alpha:]]/
|
269
|
+
letter = modify_letter_input(letter)
|
270
|
+
if !need_confirm || (confirm_action? "Swap '#{letter}' for another?")
|
271
|
+
@moves.undo_all(current_player)
|
272
|
+
current_player.swap_letter(letter, @letter_bank)
|
273
|
+
@submitted = true
|
274
|
+
|
275
|
+
# TODO: remove magic string from last move message
|
276
|
+
current_player.last_turn = "swapped letter"
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
def skip_turn(need_confirm=true)
|
282
|
+
if !need_confirm || (confirm_action? "Are you sure you want to skip your turn?")
|
283
|
+
@moves.undo_all(current_player)
|
284
|
+
@submitted = true
|
285
|
+
|
286
|
+
# TODO: remove magic string from last move message
|
287
|
+
current_player.last_turn = "skipped turn"
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
def exit_game(need_confirm=true)
|
292
|
+
if !need_confirm || (confirm_action? "Are you sure you want to exit the game?")
|
293
|
+
@running = false
|
294
|
+
@win.close if display_on?
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def toggle_rack_visibility
|
299
|
+
@win.toggle_rack_visibility
|
300
|
+
end
|
301
|
+
|
302
|
+
def game_over
|
303
|
+
update_message "The game is over. Press any key to continue to see who won..."
|
304
|
+
@win.getch if display_on?
|
305
|
+
|
306
|
+
# Subtract 5 points for each tile remaining
|
307
|
+
@players.each do |p|
|
308
|
+
p.score -= p.letters.size * 5
|
309
|
+
end
|
310
|
+
|
311
|
+
top_score = @players.map {|p| p.score}.max
|
312
|
+
winners = @players.select{|p| p.score == top_score}.map{|p| p.name}
|
313
|
+
|
314
|
+
if winners.size == 1
|
315
|
+
update_message "And the winner is... #{winners[0]} with #{top_score} points!"
|
316
|
+
else
|
317
|
+
update_message "We have a tie! #{winners.join(', ')} all win with #{top_score} points!"
|
318
|
+
end
|
319
|
+
|
320
|
+
@win.getch if display_on?
|
321
|
+
exit_game(need_confirm=false)
|
322
|
+
end
|
323
|
+
|
324
|
+
end
|
325
|
+
end
|