""" Deals with commands and logic for hangman games. *Classes* --------- Hangman() Deals with the game logic of hangman. DrawHangman() Draws the image shown to the player. """ import datetime # Used for generating the game id import string # string.ascii_uppercase used import math # Used by DrawHangman(), mainly for drawing circles import random # Used to draw poorly import requests # Used for getting the word in Hangman.start() import discord # Used for discord.file and type hints from discord_slash.context import SlashContext # Used for typehints from PIL import ImageDraw, Image, ImageFont # Used to draw the image class Hangman(): """ Controls hangman commands and game logic. *Methods* --------- start(ctx: SlashContext) stop(ctx: SlashContext) guess(message: discord.message, user: str, guess: str) """ def __init__(self, bot): """ Initialize the class. *Attributes* ------------ draw: DrawHangman The DrawHangman used to draw the hangman image. API_url: str The url to get the words from. APIPARAMS: dict The parameters to pass to every api call. """ self.__bot = bot self.__draw = DrawHangman(bot) self.__API_url = "https://api.wordnik.com/v4/words.json/randomWords?" # pylint: disable=invalid-name api_key = self.__bot.credentials["wordnik_key"] self.__APIPARAMS = { # pylint: disable=invalid-name "hasDictionaryDef": True, "minCorpusCount": 5000, "maxCorpusCount": -1, "minDictionaryCount": 1, "maxDictionaryCount": -1, "minLength": 3, "maxLength": 11, "limit": 1, "api_key": api_key } async def start(self, ctx: SlashContext): """ Start a game of hangman. *Parameters* ------------ ctx: SlashContext The context of the command. """ await self.__bot.defer(ctx) channel = str(ctx.channel_id) user = f"#{ctx.author.id}" game = self.__bot.database["hangman games"].find_one({"_id": channel}) user_name = self.__bot.database_funcs.get_name(user) started_game = False if game is None: word = "-" while "-" in word or "." in word: response = requests.get(self.__API_url, params=self.__APIPARAMS) word = list(response.json()[0]["word"].upper()) self.__bot.log("Found the word \""+"".join(word)+"\"") guessed = [False] * len(word) game_id = datetime.datetime.now().strftime("%Y%m%d%H%M%S") new_game = { "_id": channel, "player": user, "guessed letters": [], "word": word, "game ID": game_id, "misses": 0, "guessed": guessed } self.__bot.database["hangman games"].insert_one(new_game) remaining_letters = list(string.ascii_uppercase) self.__draw.draw_image(channel) log_message = "Game started" send_message = f"{user_name} started game of hangman." started_game = True else: log_message = "There was already a game going on" send_message = self.__bot.long_strings["Hangman going on"] self.__bot.log(log_message) await ctx.send(send_message) if started_game: boards_path = "gwendolyn/resources/games/hangman_boards/" file_path = f"{boards_path}hangman_board{channel}.png" new_image = await ctx.channel.send(file=discord.File(file_path)) blank_message = await ctx.channel.send("_ _") reaction_messages = { new_image: remaining_letters[:15], blank_message: remaining_letters[15:] } old_messages = f"{new_image.id}\n{blank_message.id}" old_images_path = "gwendolyn/resources/games/old_images/" with open(old_images_path+f"hangman{channel}", "w") as file_pointer: file_pointer.write(old_messages) for message, letters in reaction_messages.items(): for letter in letters: emoji = chr(ord(letter)+127397) await message.add_reaction(emoji) async def stop(self, ctx: SlashContext): """ Stop the game of hangman. *Parameters* ------------ ctx: SlashContext The context of the command. """ channel = str(ctx.channel.id) game = self.__bot.database["hangman games"].find_one({"_id": channel}) if game is None: await ctx.send("There's no game going on") elif f"#{ctx.author.id}" != game["player"]: await ctx.send("You can't end a game you're not in") else: self.__bot.database["hangman games"].delete_one({"_id": channel}) old_images_path = "gwendolyn/resources/games/old_images/" with open(old_images_path+f"hangman{channel}", "r") as file_pointer: messages = file_pointer.read().splitlines() for message in messages: old_message = await ctx.channel.fetch_message(int(message)) self.__bot.log("Deleting old message") await old_message.delete() await ctx.send("Game stopped") async def guess(self, message: discord.Message, user: str, guess: str): """ Guess a letter. *Parameters* ------------ message: discord.Message The message that the reaction was placed on. user: str The id of the user. guess: str The guess. """ channel = str(message.channel.id) hangman_games = self.__bot.database["hangman games"] game = hangman_games.find_one({"_id": channel}) game_exists = (game is not None) single_letter = (len(guess) == 1 and guess.isalpha()) new_guess = (guess not in game["guessed letters"]) valid_guess = (game_exists and single_letter and new_guess) if valid_guess: self.__bot.log("Guessed the letter") correct_guess = 0 for i, letter in enumerate(game["word"]): if guess == letter: correct_guess += 1 updater = {"$set": {f"guessed.{i}": True}} hangman_games.update_one({"_id": channel}, updater) if correct_guess == 0: updater = {"$inc": {"misses": 1}} hangman_games.update_one({"_id": channel}, updater) updater = {"$push": {"guessed letters": guess}} hangman_games.update_one({"_id": channel}, updater) remaining_letters = list(string.ascii_uppercase) game = hangman_games.find_one({"_id": channel}) for letter in game["guessed letters"]: remaining_letters.remove(letter) if correct_guess == 1: send_message = "Guessed {}. There was 1 {} in the word." send_message = send_message.format(guess, guess) else: send_message = "Guessed {}. There were {} {}s in the word." send_message = send_message.format(guess, correct_guess, guess) self.__draw.draw_image(channel) if game["misses"] == 6: hangman_games.delete_one({"_id": channel}) send_message += self.__bot.long_strings["Hangman lost game"] remaining_letters = [] elif all(game["guessed"]): hangman_games.delete_one({"_id": channel}) self.__bot.money.addMoney(user, 15) send_message += self.__bot.long_strings["Hangman guessed word"] remaining_letters = [] await message.channel.send(send_message) old_images_path = "gwendolyn/resources/games/old_images/" with open(old_images_path+f"hangman{channel}", "r") as file_pointer: old_message_ids = file_pointer.read().splitlines() for old_id in old_message_ids: old_message = await message.channel.fetch_message(int(old_id)) self.__bot.log("Deleting old message") await old_message.delete() boards_path = "gwendolyn/resources/games/hangman_boards/" file_path = f"{boards_path}hangman_board{channel}.png" new_image = await message.channel.send(file=discord.File(file_path)) if len(remaining_letters) > 0: if len(remaining_letters) > 15: blank_message = await message.channel.send("_ _") reaction_messages = { new_image: remaining_letters[:15], blank_message: remaining_letters[15:] } else: blank_message = "" reaction_messages = {new_image: remaining_letters} if blank_message != "": old_messages = f"{new_image.id}\n{blank_message.id}" else: old_messages = str(new_image.id) old_images_path = "gwendolyn/resources/games/old_images/" old_image_path = old_images_path + f"hangman{channel}" with open(old_image_path, "w") as file_pointer: file_pointer.write(old_messages) for message, letters in reaction_messages.items(): for letter in letters: emoji = chr(ord(letter)+127397) await message.add_reaction(emoji) class DrawHangman(): """ Draws the image of the hangman game. *Methods* --------- draw_image(channel: str) """ def __init__(self, bot): """ Initialize the class. *Attributes* ------------ CIRCLESIZE LINEWIDTH BODYSIZE LIMBSIZE ARMPOSITION MANX, MANY LETTERLINELENGTH LETTERLINEDISTANCE GALLOWX, GALLOWY PHI FONT SMALLFONT """ self.__bot = bot # pylint: disable=invalid-name self.__CIRCLESIZE = 120 self.__LINEWIDTH = 12 self.__BODYSIZE = 210 self.__LIMBSIZE = 60 self.__ARMPOSITION = 60 self.__MANX = (self.__LIMBSIZE*2) self.__MANY = (self.__CIRCLESIZE+self.__BODYSIZE+self.__LIMBSIZE) MANPADDING = self.__LINEWIDTH*4 self.__MANX += MANPADDING self.__MANY += MANPADDING self.__LETTERLINELENGTH = 90 self.__LETTERLINEDISTANCE = 30 self.__GALLOWX, self.__GALLOWY = 360, 600 self.__PHI = 1-(1 / ((1 + 5 ** 0.5) / 2)) LETTERSIZE = 75 # Wrong guesses letter size WORDSIZE = 70 # Correct guesses letter size FONTPATH = "gwendolyn/resources/fonts/comic-sans-bold.ttf" self.__FONT = ImageFont.truetype(FONTPATH, LETTERSIZE) self.__SMALLFONT = ImageFont.truetype(FONTPATH, WORDSIZE) # pylint: enable=invalid-name def __deviate(self, pre_deviance: int, pre_deviance_accuracy: int, position_change: float, maxmin: int, max_acceleration: float): random_deviance = random.uniform(-position_change, position_change) deviance_accuracy = pre_deviance_accuracy + random_deviance if deviance_accuracy > maxmin * max_acceleration: deviance_accuracy = maxmin * max_acceleration elif deviance_accuracy < -maxmin * max_acceleration: deviance_accuracy = -maxmin * max_acceleration deviance = pre_deviance + deviance_accuracy if deviance > maxmin: deviance = maxmin elif deviance < -maxmin: deviance = -maxmin return deviance, deviance_accuracy def __bad_circle(self): circle_padding = (self.__LINEWIDTH*3) image_width = self.__CIRCLESIZE+circle_padding image_size = (image_width, image_width) background = Image.new("RGBA", image_size, color=(0, 0, 0, 0)) drawer = ImageDraw.Draw(background, "RGBA") middle = (self.__CIRCLESIZE+(self.__LINEWIDTH*3))/2 deviance_x = 0 deviance_y = 0 deviance_accuracy_x = 0 deviance_accuracy_y = 0 start = random.randint(-100, -80) degreess_amount = 360 + random.randint(-10, 30) for degree in range(degreess_amount): deviance_x, deviance_accuracy_x = self.__deviate( deviance_x, deviance_accuracy_x, self.__LINEWIDTH/100, self.__LINEWIDTH, 0.03 ) deviance_y, deviance_accuracy_y = self.__deviate( deviance_y, deviance_accuracy_y, self.__LINEWIDTH/100, self.__LINEWIDTH, 0.03 ) radians = math.radians(degree+start) circle_x = (math.cos(radians) * (self.__CIRCLESIZE/2)) circle_y = (math.sin(radians) * (self.__CIRCLESIZE/2)) position_x = middle + circle_x - (self.__LINEWIDTH/2) + deviance_x position_y = middle + circle_y - (self.__LINEWIDTH/2) + deviance_y circle_position = [ (position_x, position_y), (position_x+self.__LINEWIDTH, position_y+self.__LINEWIDTH) ] drawer.ellipse(circle_position, fill=(0, 0, 0, 255)) return background def __bad_line(self, length: int, rotated: bool = False): if rotated: width, height = length+self.__LINEWIDTH*3, self.__LINEWIDTH*3 else: width, height = self.__LINEWIDTH*3, length+self.__LINEWIDTH*3 background = Image.new("RGBA", (width, height), color=(0, 0, 0, 0)) drawer = ImageDraw.Draw(background, "RGBA") possible_deviance = int(self.__LINEWIDTH/3) deviance_x = random.randint(-possible_deviance, possible_deviance) deviance_y = 0 deviance_accuracy_x = 0 deviance_accuracy_y = 0 for pixel in range(length): deviance_x, deviance_accuracy_x = self.__deviate( deviance_x, deviance_accuracy_x, self.__LINEWIDTH/1000, self.__LINEWIDTH, 0.004 ) deviance_y, deviance_accuracy_y = self.__deviate( deviance_y, deviance_accuracy_y, self.__LINEWIDTH/1000, self.__LINEWIDTH, 0.004 ) if rotated: position_x = self.__LINEWIDTH + pixel + deviance_x position_y = self.__LINEWIDTH + deviance_y else: position_x = self.__LINEWIDTH + deviance_x position_y = self.__LINEWIDTH + pixel + deviance_y circle_position = [ (position_x, position_y), (position_x+self.__LINEWIDTH, position_y+self.__LINEWIDTH) ] drawer.ellipse(circle_position, fill=(0, 0, 0, 255)) return background def __draw_man(self, misses: int, seed: str): random.seed(seed) man_size = (self.__MANX, self.__MANY) background = Image.new("RGBA", man_size, color=(0, 0, 0, 0)) if misses >= 1: head = self.__bad_circle() paste_x = (self.__MANX-(self.__CIRCLESIZE+(self.__LINEWIDTH*3)))//2 paste_position = (paste_x, 0) background.paste(head, paste_position, head) if misses >= 2: body = self.__bad_line(self.__BODYSIZE) paste_x = (self.__MANX-(self.__LINEWIDTH*3))//2 paste_position = (paste_x, self.__CIRCLESIZE) background.paste(body, paste_position, body) if misses >= 3: limbs = random.sample(["rl", "ll", "ra", "la"], min(misses-2, 4)) else: limbs = [] random.seed(seed) for limb in limbs: limb_drawing = self.__bad_line(self.__LIMBSIZE, True) x_position = (self.__MANX-(self.__LINEWIDTH*3))//2 if limb[1] == "a": rotation = random.randint(-45, 45) shift = math.sin(math.radians(rotation)) line_length = self.__LIMBSIZE+(self.__LINEWIDTH*3) compensation = int(shift*line_length) limb_drawing = limb_drawing.rotate(rotation, expand=1) y_position = self.__CIRCLESIZE + self.__ARMPOSITION if limb == "ra": compensation = min(-compensation, 0) else: x_position -= self.__LIMBSIZE compensation = min(compensation, 0) y_position += compensation else: rotation = random.randint(-15, 15) y_position = self.__CIRCLESIZE+self.__BODYSIZE-self.__LINEWIDTH if limb == "rl": limb_drawing = limb_drawing.rotate(rotation-45, expand=1) else: x_position += -limb_drawing.size[0]+self.__LINEWIDTH*3 limb_drawing = limb_drawing.rotate(rotation+45, expand=1) paste_position = (x_position, y_position) background.paste(limb_drawing, paste_position, limb_drawing) return background def __bad_text(self, text: str, big: bool, color: tuple = (0, 0, 0, 255)): if big: font = self.__FONT else: font = self.__SMALLFONT width, height = font.getsize(text) img = Image.new("RGBA", (width, height), color=(0, 0, 0, 0)) drawer = ImageDraw.Draw(img, "RGBA") drawer.text((0, 0), text, font=font, fill=color) return img def __draw_gallows(self): gallow_size = (self.__GALLOWX, self.__GALLOWY) background = Image.new("RGBA", gallow_size, color=(0, 0, 0, 0)) bottom_line = self.__bad_line(int(self.__GALLOWX * 0.75), True) bottom_line_x = int(self.__GALLOWX * 0.125) bottom_line_y = self.__GALLOWY-(self.__LINEWIDTH*4) paste_position = (bottom_line_x, bottom_line_y) background.paste(bottom_line, paste_position, bottom_line) line_two = self.__bad_line(self.__GALLOWY-self.__LINEWIDTH*6) line_two_x = int(self.__GALLOWX*(0.75*self.__PHI)) line_two_y = self.__LINEWIDTH*2 paste_position = (line_two_x, line_two_y) background.paste(line_two, paste_position, line_two) top_line = self.__bad_line(int(self.__GALLOWY*0.30), True) paste_x = int(self.__GALLOWX*(0.75*self.__PHI))-self.__LINEWIDTH paste_position = (paste_x, self.__LINEWIDTH*3) background.paste(top_line, paste_position, top_line) last_line = self.__bad_line(int(self.__GALLOWY*0.125)) paste_x += int(self.__GALLOWY*0.30) background.paste(last_line, (paste_x, self.__LINEWIDTH*3), last_line) return background def __draw_letter_lines(self, word: str, guessed: list, misses: int): letter_width = self.__LETTERLINELENGTH+self.__LETTERLINEDISTANCE image_width = letter_width*len(word) image_size = (image_width, self.__LETTERLINELENGTH+self.__LINEWIDTH*3) letter_lines = Image.new("RGBA", image_size, color=(0, 0, 0, 0)) for i, letter in enumerate(word): line = self.__bad_line(self.__LETTERLINELENGTH, True) paste_x = i*(self.__LETTERLINELENGTH+self.__LETTERLINEDISTANCE) paste_position = (paste_x, self.__LETTERLINELENGTH) letter_lines.paste(line, paste_position, line) if guessed[i]: letter_drawing = self.__bad_text(letter, True) letter_width = self.__FONT.getsize(letter)[0] letter_x = i*(self.__LETTERLINELENGTH+self.__LETTERLINEDISTANCE) letter_x -= (letter_width//2) letter_x += (self.__LETTERLINELENGTH//2)+(self.__LINEWIDTH*2) letter_lines.paste( letter_drawing, (letter_x, 0), letter_drawing ) elif misses == 6: letter_drawing = self.__bad_text(letter, True, (242, 66, 54)) letter_width = self.__FONT.getsize(letter)[0] letter_x = i*(self.__LETTERLINELENGTH+self.__LETTERLINEDISTANCE) letter_x -= (letter_width//2) letter_x += (self.__LETTERLINELENGTH//2)+(self.__LINEWIDTH*2) letter_lines.paste( letter_drawing, (letter_x, 0), letter_drawing ) return letter_lines def __shortest_dist(self, positions: list, new_position: tuple): shortest_dist = math.inf for i, j in positions: x_distance = abs(i-new_position[0]) y_distance = abs(j-new_position[1]) dist = math.sqrt(x_distance**2+y_distance**2) if shortest_dist > dist: shortest_dist = dist return shortest_dist def __draw_misses(self, guesses: list, word: str): background = Image.new("RGBA", (600, 400), color=(0, 0, 0, 0)) pos = [] for guess in guesses: if guess not in word: placed = False while not placed: letter = self.__bad_text(guess, True) w, h = self.__FONT.getsize(guess) x = random.randint(0, 600-w) y = random.randint(0, 400-h) if self.__shortest_dist(pos, (x, y)) > 70: pos.append((x, y)) background.paste(letter, (x, y), letter) placed = True return background def draw_image(self, channel: str): """ Draw a hangman Image. *Parameters* ------------ channel: str The id of the channel the game is in. """ self.__bot.log("Drawing hangman image", channel) game = self.__bot.database["hangman games"].find_one({"_id": channel}) random.seed(game["game ID"]) background = Image.open("gwendolyn/resources/paper.jpg") gallow = self.__draw_gallows() man = self.__draw_man(game["misses"], game["game ID"]) random.seed(game["game ID"]) letter_line_parameters = [game["word"], game["guessed"], game["misses"]] letter_lines = self.__draw_letter_lines(*letter_line_parameters) random.seed(game["game ID"]) misses = self.__draw_misses(game["guessed letters"], game["word"]) background.paste(gallow, (100, 100), gallow) background.paste(man, (300, 210), man) background.paste(letter_lines, (120, 840), letter_lines) background.paste(misses, (600, 150), misses) misses_text = self.__bad_text("MISSES", False) misses_text_width = misses_text.size[0] background.paste(misses_text, (850-misses_text_width//2, 50), misses_text) board_path = f"gwendolyn/resources/games/hangman_boards/hangman_board{channel}.png" background.save(board_path)