upwords 0.1.1

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