Example #1
0
class Hangman:

	'''
	This class provides the context for the
	graphics and logic modules, as well as
	handling input, menus, events and
	resources.

	The original intent was to let it serve
	as a model-view controller, where Logic
	represented the model and Graphics the
	main view.

	'''

	def __init__(self):

		'''
		Initializes window, canvas, gameplay options and menus,
		loads resources (settings, images, dictionaries)
		and sets up debugging.

		'''

		# Window
		self.size = Size(650, 650)
		self.root = self.createWindow(self.size)
		self.icon = self.loadIcon('icon.png')

		# Internal settings
		self.validState = False 						# Not ready to accept guesses
		self.DEBUG 		= tk.BooleanVar(value=False)	# Print debug messages
		self.VERBOSE 	= tk.BooleanVar(value=True)		# Print verbose debug messages

		# Logging
		self.messages 	= []
		self.logger = Logger('Hangman')

		# Resources
		self.dictData 	= self.loadDictionaries('data/dicts/dictionaries.json')
		self.dictNames 	= [name for name in self.dictData.keys()]
		self.flags 		= self.loadFlags()

		# Gameplay settings
		self.restartDelay   = 1500 	# Delay before new round begins (ms)
		self.revealWhenLost = False	# Reveal the word when the game is lost
		
		# TODO: Save reference to current dict (?)
		self.DICT 			= tk.StringVar(value=choice(self.dictNames)) 	# Select random dictionary
		self.characterSet 	= self.dictData[self.DICT.get()]['characters'] 	# TODO: Make this dictionary-dependent

		# Menus
		self.menubar = self.createMenus()

		# Events
		self.bindEvents()

		# Game play
		self.graphics = Graphics(self.root, *self.size, characterSet=self.characterSet)	# Renderer
		self.logic 	  = Logic(self.graphics.chances)									# Logic
		self.wordFeed = self.createWordFeed(self.DICT.get()) 							# Provides a stream of words and hints
		self.chances  = self.graphics.chances 											# Initial number of chances for each round

		self.word = None # Initialized later on
		self.hint = None # Initialized later on

		# Audio
		self.effects = self.loadAudio()


	def play(self):

		''' Starts the game '''

		self.restart()
		self.root.mainloop()


	def createWindow(self, size):

		''' As per the title '''

		root = tk.Tk()
		root.resizable(width=False, height=False)
		root.title('Hangman')

		return root


	def createMenus(self):

		''' As per the title '''

		# TODO: Nested dict or JSON menu definition (?)
		# TODO: Desperately needs a clean-up (...)

		menubar = tk.Menu(self.root)

		# New game
		menubar.add_command(label='New', command=self.restart)

		# Settings
		settings = tk.Menu(menubar, tearoff=0)
		settings.add_command(label='Difficulty', command=lambda: print('Moderate'))

		# Languages
		languages = tk.Menu(settings, tearoff=0)

		self.DICT.trace('w', lambda *args, var=self.DICT: self.setDictionary(var.get())) # TODO: Extract traces

		# TODO: Use appropriate flag
		for N, name in enumerate(self.dictData.keys()):
			code = self.dictData[name]['iso'] # Language code
			languages.add_radiobutton(label=name, image=self.flags[code], compound='left', var=self.DICT, value=name)
		else:
			self.logger.log('Found %d dictionaries.' % (N+1), kind='log')

		settings.add_cascade(label='Language', menu=languages)
		menubar.add_cascade(label='Settings', menu=settings)

		# About box
		menubar.add_command(label='About', command=self.about)
	
		# Dev menu
		debug = tk.Menu(menubar, tearoff=0)
		debug.add_checkbutton(label='Debug', onvalue=True, offvalue=False, variable=self.DEBUG)
		debug.add_checkbutton(label='Verbose', onvalue=True, offvalue=False, variable=self.VERBOSE)
		menubar.add_cascade(label='Dev', menu=debug)

		# Quit
		menubar.add_command(label='Quit', command=self.quit)

		# Attach to window
		self.root.config(menu=menubar)
		
		return menubar


	def bindEvents(self):
		''' Binds callbacks to events '''
		self.root.bind('<Escape>', lambda e: self.quit())
		self.root.bind('<Key>', lambda e: self.onKeyDown(e))


	def onKeyDown(self, event):

		''' Responds to key presses '''
		
		# TODO: Make sure guesses can't be made in a transitory state (✓)
		# TODO: Tidy up
		# TODO: Shortcuts, key bindings with JSON (?)
		# Validate the guess

		# Make sure the game is in a valid state (eg. not between to rounds)
		if not self.validState:
			self.logger.log('Cannot accept guesses right now', kind='log')
			return
		elif not self.validGuess(event.char):
			return
		else:
			self.guess(event.char)


	def about(self):
		''' Shows an about box '''
		messagebox.askokcancel('About', 'Hangman\nJonatan H Sundqvist\nJuly 2014')


	def loadAudio(self):
		''' Initializes mixer and loads sound effects '''
		mixer.init()
		files = ['strangled.wav', 'ding.wav']
		return namedtuple('Effects', ['lose', 'win'])(*map(lambda fn: mixer.Sound('data/audio/%s' % fn), files))


	def loadFlags(self):
		''' Loads all flag files and creates a map between those and their respective ISO language codes '''
		codes = [('en-uk', 'UK.png'), ('es-es', 'Spain.png'), ('in', 'India.png'), ('sv-sv', 'sv.png'), ('en-us', 'USA.png')] # Maps language codes to flags
		flags = {}
		for iso, fn in codes:
			image = Image.open('data/flags/%s' % fn)
			image.thumbnail((16,16), Image.ANTIALIAS)
			
			flags[iso] = ImageTk.PhotoImage(image)
		return flags


	def loadIcon(self, fn):
		''' Loads and sets the title bar icon '''
		icon = ImageTk.PhotoImage(Image.open(fn))
		self.root.call('wm', 'iconphoto', self.root._w, icon)
		return icon


	def loadDictionaries(self, fn):
		''' Loads JSON dictionary meta data '''
		# TODO: Dot notation
		# TODO: Load associated resources for convenience (?)
		with open(fn, 'r', encoding='utf-8') as dicts:
			return json.load(dicts)


	def setDictionary(self, name):
		''' Sets the dictionary specified by the name and restarts '''
		self.logger.log('Changing dictionary to %s' % name, kind='log')
		self.wordFeed = self.createWordFeed(name)
		self.characterSet = self.dictData[name]['characters']
		self.graphics.changeCharacterSet(self.characterSet)
		self.restart()


	# TODO: Research Python annotation syntax
	# TOOD: Check if ST3 has support for the same
	#def guess(self : str, letter : str) -> None:
	def guess(self, letter):
		
		''' Guesses one letter '''

		# TODO: Write a slightly more helpful docstring
		# TODO: Clean this up

		result = self.logic.guess(letter)
		
		self.logger.log('\'%s\' is a %s!' % (letter.upper(), result), kind='log')

		self.graphics.guess(letter, result in ('MATCH', 'WIN'), str(self.logic)), # TODO: Let Graphics take care of the representation for us (?)
		
		# TODO: Clean up the 'switch' logic
		#{'WIN': self.win, 'LOSE': self.lose}.get(result, lambda: None)()
		# return { ('MATCH', 'WIN'):  }
		
		if result == 'WIN':
			self.win()
		elif result == 'LOSE':
			self.lose()


	def validGuess(self, letter):
		''' Determines if a letter is a valid guess '''

		# TODO: Make this configurable (list of requirements?)

		# Normalize input
		letter = letter.upper()

		# Check all requirements
		if letter not in self.characterSet:
			# Make sure the character is a guessable letter
			self.logger.log('\'%s\' is not in the character set!' % letter, kind='log')
			return False
		elif len(letter) != 1:
			# Make sure the guess is only one letter
			self.logger.log('\'%s\' does not have exactly one letter!' % letter, kind='log')
			return False
		elif self.logic.hasGuessed(letter):
			# Make sure it's not a repeat guess
			self.logger.log('\'%s\' has already been guessed!' % letter, kind='log')
			return False
		else:
			return True


	def win(self):
		''' Victorious feedback, schedules the next round '''
		self.logger.log('Phew. You figured it out!', kind='log')
		self.effects.win.play()
		self.scheduleRestart()
	
	
	def lose(self):
		''' Failure feedback, schedules the next round '''
		# TODO: Show correct word before restarting (?)
		self.logger.log('You\'ve been hanged. Requiescat in pace!', kind='log')
		self.effects.lose.play()
		if self.revealWhenLost:
			self.graphics.setWord(self.word) # Reveal answer
		self.scheduleRestart()


	def scheduleRestart(self):
		''' Schedules a new round and disables guessing in the interim '''
		self.validState = False # Disable guessing between rounds
		self.root.after(self.restartDelay, self.restart)
		

	def restart(self):
		''' Starts a new game '''
		self.logger.log('\n{0}\n{1:^40}\n{0}\n'.format('-'*40, 'NEW GAME'), kind='log', identify=False) # TODO: Enable identify options in Logger.log

		self.word, self.hint = next(self.wordFeed)
		self.graphics.showHint(self.hint)

		self.logic.new(self.word)
		self.graphics.play(str(self.logic))

		self.validState = True # Ready to accept guesses again


	def quit(self, message=None, prompt=False):
		''' Exits the application '''
		# TODO: Find a way to close Python
		#print('\n'.join(self.messages))
		self.root.quit()


	def createWordFeed(self, name):
		''' Creates a word feed from the dictionary specified by the name '''
		# TODO: Give class a reference to words (?)
		# TODO: Wise to hard-code path (?)
		# TODO: Handle incorrectly structured dictionaries
		self.logger.log('Creating word feed from \'{name}\'.'.format(name=name), kind='log')
		fn = self.dictData[name]['file']
		with open('data/dicts/%s' %  fn, 'r', encoding='utf-8') as wordFile:
			words = wordFile.read().split('\n')
		while True:
			try:
				line = choice(words)
				word, hint = line.split('|') # Word|Hint
				yield word, hint
			except ValueError as e:
				words.remove(line) # Remove the culprit from the word feed
				self.logger.log('Removing invalid definition ({0}).'.format(line), kind='error')