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.
data/exe/upwords ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'upwords'
4
+
5
+ Upwords::Game.new.run
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
@@ -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