""" 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 os 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.utils.manage_components import (create_button, create_actionrow) from discord_slash.model import ButtonStyle from discord_slash.context import SlashContext, ComponentContext # Used for typehints from PIL import ImageDraw, Image, ImageFont # Used to draw the image from gwendolyn.utils import encode_id 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) 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") remaining_letters = list(string.ascii_uppercase) self._draw.draw_image(game_id, 0, word, guessed, []) send_message = f"{ctx.author.display_name} started a game of hangman." self.bot.log("Game started") await ctx.send(send_message) boards_path = "gwendolyn/resources/games/hangman_boards/" file_path = f"{boards_path}hangman_board{game_id}.png" image_message = await ctx.channel.send( file=discord.File(file_path) ) blank_message_one = await ctx.channel.send("_ _") blank_message_two = await ctx.channel.send("_ _") buttons = [] for letter in remaining_letters: custom_id = encode_id([ "hangman", "guess", str(ctx.author.id), letter, "".join(word), "", game_id, str(image_message.id), str(blank_message_one.id), str(blank_message_two.id) ]) buttons.append(create_button( style=ButtonStyle.blue, label=letter, custom_id=custom_id )) custom_id = encode_id([ "hangman", "end", str(ctx.author.id), game_id, str(image_message.id), str(blank_message_one.id), str(blank_message_two.id) ]) buttons.append(create_button( style=ButtonStyle.red, label="End game", custom_id=custom_id )) action_row_lists = [] for i in range(((len(buttons)-1)//20)+1): action_rows = [] available_buttons = buttons[(i*20):(min(len(buttons),i*20+20))] for x in range(((len(available_buttons)-1)//4)+1): row_buttons = available_buttons[ (x*4):(min(len(available_buttons),x*4+4)) ] action_rows.append(create_actionrow(*row_buttons)) action_row_lists.append(action_rows) await blank_message_one.edit(components=action_row_lists[0]) await blank_message_two.edit(components=action_row_lists[1]) async def stop(self, ctx: ComponentContext, game_id: str, *messages: list[str]): """ Stop the game of hangman. *Parameters* ------------ ctx: SlashContext The context of the command. """ for msg_id in messages: msg = await ctx.channel.fetch_message(msg_id) await msg.delete() self.bot.log("Deleting old messages") await ctx.channel.send("Hangman game stopped") boards_path = "gwendolyn/resources/games/hangman_boards/" file_path = f"{boards_path}hangman_board{game_id}.png" os.remove(file_path) async def guess(self, ctx: ComponentContext, guess: str, word: str, guessed_letters: str, game_id: str, *messages: list[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. """ for msg_id in messages: msg = await ctx.channel.fetch_message(msg_id) await msg.delete() misses = 0 guessed_letters += guess guessed = [False for i in range(len(word))] for guessed_letter in guessed_letters: correct_guess = 0 for i, letter in enumerate(word): if guessed_letter == letter: correct_guess += 1 guessed[i] = True if correct_guess == 0: misses += 1 remaining_letters = list(string.ascii_uppercase) for letter in 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(game_id, misses, word, guessed, guessed_letters) if misses == 6: send_message += self.bot.long_strings["Hangman lost game"] remaining_letters = [] elif all(guessed): self.bot.money.addMoney(f"#{ctx.author_id}", 15) send_message += self.bot.long_strings["Hangman guessed word"] remaining_letters = [] if remaining_letters != []: await ctx.channel.send(send_message) boards_path = "gwendolyn/resources/games/hangman_boards/" file_path = f"{boards_path}hangman_board{game_id}.png" image_message = await ctx.channel.send( file=discord.File(file_path) ) blank_message_one = await ctx.channel.send("_ _") blank_message_two = await ctx.channel.send("_ _") buttons = [] for letter in list(string.ascii_uppercase): custom_id = encode_id([ "hangman", "guess", str(ctx.author.id), letter, word, guessed_letters, game_id, str(image_message.id), str(blank_message_one.id), str(blank_message_two.id) ]) buttons.append(create_button( style=ButtonStyle.blue, label=letter, custom_id=custom_id, disabled=(letter not in remaining_letters) )) custom_id = encode_id([ "hangman", "end", str(ctx.author.id), game_id, str(image_message.id), str(blank_message_one.id), str(blank_message_two.id) ]) buttons.append(create_button( style=ButtonStyle.red, label="End game", custom_id=custom_id )) action_row_lists = [] for i in range(((len(buttons)-1)//20)+1): action_rows = [] available_buttons = buttons[(i*20):(min(len(buttons),i*20+20))] for x in range(((len(available_buttons)-1)//4)+1): row_buttons = available_buttons[ (x*4):(min(len(available_buttons),x*4+4)) ] action_rows.append(create_actionrow(*row_buttons)) action_row_lists.append(action_rows) await blank_message_one.edit(components=action_row_lists[0]) await blank_message_two.edit(components=action_row_lists[1]) else: boards_path = "gwendolyn/resources/games/hangman_boards/" file_path = f"{boards_path}hangman_board{game_id}.png" await ctx.channel.send(send_message, file=discord.File(file_path)) os.remove(file_path) 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) degrees_amount = 360 + random.randint(-10, 30) for degree in range(degrees_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, game_id: str, misses: int, word: str, guessed: list[bool], guessed_letters: str): """ Draw a hangman Image. *Parameters* ------------ channel: str The id of the channel the game is in. """ self._bot.log("Drawing hangman image") random.seed(game_id) background = Image.open("gwendolyn/resources/games/default_images/hangman.png") gallow = self._draw_gallows() man = self._draw_man(misses, game_id) random.seed(game_id) letter_line_parameters = [word, guessed, misses] letter_lines = self._draw_letter_lines(*letter_line_parameters) random.seed(game_id) misses = self._draw_misses(list(guessed_letters), 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) boards_path = "gwendolyn/resources/games/hangman_boards/" image_path = f"{boards_path}hangman_board{game_id}.png" background.save(image_path)