import discord from discord import ui from typing import List, Dict, Optional, Set import io from PIL import Image, ImageDraw, ImageFont import os class WordleGame: """Class to handle Wordle game logic""" def __init__(self, word: str, max_attempts: int = 6): """ Initialize a new Wordle game Args: word: The target word to guess max_attempts: Maximum number of attempts allowed (default: 6) """ self.word = word.lower() self.max_attempts = max_attempts self.attempts = 0 self.guesses = [] self.game_over = False self.won = False def make_guess(self, guess: str) -> List[Dict[str, str]]: """ Process a guess and return the result Args: guess: The word guessed by the player Returns: List of dictionaries with letter and status (correct, present, absent) """ if self.game_over: return [] guess = guess.lower() if len(guess) != len(self.word): return [] self.attempts += 1 self.guesses.append(guess) # Check if the guess is correct if guess == self.word: self.game_over = True self.won = True # Check if max attempts reached if self.attempts >= self.max_attempts: self.game_over = True # Process the guess return self.evaluate_guess(guess) def evaluate_guess(self, guess: str) -> List[Dict[str, str]]: """ Evaluate a guess without modifying game state Args: guess: The word to evaluate Returns: List of dictionaries with letter and status (correct, present, absent) """ guess = guess.lower() if len(guess) != len(self.word): return [] result = [] word_chars = list(self.word) # First pass: Find exact matches for i, char in enumerate(guess): if i < len(self.word) and char == self.word[i]: result.append({"letter": char, "status": "correct"}) word_chars[i] = None # Mark as used else: result.append({"letter": char, "status": "unknown"}) # Second pass: Find misplaced letters for i, item in enumerate(result): if item["status"] == "unknown": char = item["letter"] if char in word_chars: # Letter exists but in wrong position result[i]["status"] = "present" word_chars[word_chars.index(char)] = None # Mark as used else: # Letter doesn't exist in the word result[i]["status"] = "absent" return result def generate_board_image( game: WordleGame, used_letters: Set[str] = None ) -> discord.File: """ Generate an image of the Wordle game board Args: game: The WordleGame instance used_letters: Set of letters that have been used in guesses Returns: discord.File: The image file to send """ # Define colors and dimensions CORRECT_COLOR = (106, 170, 100) # Green PRESENT_COLOR = (201, 180, 88) # Yellow ABSENT_COLOR = (120, 124, 126) # Gray UNUSED_COLOR = (211, 214, 218) # Light gray BACKGROUND_COLOR = (255, 255, 255) # White TEXT_COLOR = (0, 0, 0) # Black SQUARE_SIZE = 60 SQUARE_MARGIN = 5 KEYBOARD_MARGIN_TOP = 30 KEYBOARD_SQUARE_SIZE = 40 KEYBOARD_SQUARE_MARGIN = 4 # Calculate board dimensions word_length = len(game.word) max_attempts = game.max_attempts # Calculate total width and height board_width = (SQUARE_SIZE + SQUARE_MARGIN) * word_length + SQUARE_MARGIN board_height = (SQUARE_SIZE + SQUARE_MARGIN) * max_attempts + SQUARE_MARGIN # Add space for keyboard keyboard_rows = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"] keyboard_width = ( max(len(row) for row in keyboard_rows) * (KEYBOARD_SQUARE_SIZE + KEYBOARD_SQUARE_MARGIN) + KEYBOARD_SQUARE_MARGIN ) keyboard_height = ( len(keyboard_rows) * (KEYBOARD_SQUARE_SIZE + KEYBOARD_SQUARE_MARGIN) + KEYBOARD_SQUARE_MARGIN ) # Add space for status text status_height = 40 # Total image dimensions total_width = max(board_width, keyboard_width) + 40 # Add padding total_height = ( board_height + KEYBOARD_MARGIN_TOP + keyboard_height + status_height + 40 ) # Add padding # Create image img = Image.new("RGB", (total_width, total_height), BACKGROUND_COLOR) draw = ImageDraw.Draw(img) # Try to load font font = None try: # Construct path relative to this script file SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname( os.path.dirname(SCRIPT_DIR) ) # Go up two levels from games dir FONT_DIR_NAME = "FONT" # Directory specified by user FONT_FILE_NAME = "DejaVuSans.ttf" font_path = os.path.join(PROJECT_ROOT, FONT_DIR_NAME, FONT_FILE_NAME) print(f"Font path: {font_path}") # Try to load the font font = ImageFont.truetype(font_path, size=int(SQUARE_SIZE * 0.6)) small_font = ImageFont.truetype(font_path, size=int(KEYBOARD_SQUARE_SIZE * 0.6)) status_font = ImageFont.truetype(font_path, size=int(status_height * 0.6)) except Exception as e: print(f"Error loading font: {e}") # Fallback to default font font = ImageFont.load_default() small_font = ImageFont.load_default() status_font = ImageFont.load_default() # Calculate starting positions to center the board start_x = (total_width - board_width) // 2 start_y = 20 # Top padding # Draw the board for row in range(max_attempts): for col in range(word_length): # Calculate square position x = start_x + col * (SQUARE_SIZE + SQUARE_MARGIN) + SQUARE_MARGIN y = start_y + row * (SQUARE_SIZE + SQUARE_MARGIN) + SQUARE_MARGIN # Default to empty square square_color = UNUSED_COLOR letter = "" # If there's a guess for this row, fill in the square if row < len(game.guesses): guess = game.guesses[row] if col < len(guess): letter = guess[col].upper() # Get the status of this letter result = game.evaluate_guess(guess) status = result[col]["status"] if status == "correct": square_color = CORRECT_COLOR elif status == "present": square_color = PRESENT_COLOR else: # absent square_color = ABSENT_COLOR # Draw the square draw.rectangle( [x, y, x + SQUARE_SIZE, y + SQUARE_SIZE], fill=square_color, outline=(0, 0, 0), width=2, ) # Draw the letter if there is one if letter: # Center the text in the square try: bbox = draw.textbbox((0, 0), letter, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] except AttributeError: # Fallback for older Pillow versions try: text_width, text_height = draw.textsize(letter, font=font) except: text_width, text_height = font.getsize(letter) text_x = x + (SQUARE_SIZE - text_width) // 2 text_y = y + (SQUARE_SIZE - text_height) // 2 # White text on colored backgrounds text_color = ( (255, 255, 255) if square_color != UNUSED_COLOR else TEXT_COLOR ) draw.text((text_x, text_y), letter, fill=text_color, font=font) # Draw the keyboard if used_letters: keyboard_start_y = start_y + board_height + KEYBOARD_MARGIN_TOP for row_idx, row in enumerate(keyboard_rows): # Center this row of keys row_width = ( len(row) * (KEYBOARD_SQUARE_SIZE + KEYBOARD_SQUARE_MARGIN) + KEYBOARD_SQUARE_MARGIN ) row_start_x = (total_width - row_width) // 2 for col_idx, key in enumerate(row): key_lower = key.lower() x = ( row_start_x + col_idx * (KEYBOARD_SQUARE_SIZE + KEYBOARD_SQUARE_MARGIN) + KEYBOARD_SQUARE_MARGIN ) y = ( keyboard_start_y + row_idx * (KEYBOARD_SQUARE_SIZE + KEYBOARD_SQUARE_MARGIN) + KEYBOARD_SQUARE_MARGIN ) # Default color for unused keys key_color = UNUSED_COLOR if key_lower in used_letters: # Determine the best status for this letter across all guesses status = "absent" # Default to absent # Check if this letter appears as correct in any position correct_position = False present_position = False for guess in game.guesses: result = game.evaluate_guess(guess) for i, char in enumerate(guess): if char == key_lower: if result[i]["status"] == "correct": correct_position = True break elif result[i]["status"] == "present": present_position = True if correct_position: key_color = CORRECT_COLOR elif present_position: key_color = PRESENT_COLOR else: key_color = ABSENT_COLOR # Draw the key draw.rectangle( [x, y, x + KEYBOARD_SQUARE_SIZE, y + KEYBOARD_SQUARE_SIZE], fill=key_color, outline=(0, 0, 0), width=1, ) # Draw the letter try: bbox = draw.textbbox((0, 0), key, font=small_font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] except AttributeError: # Fallback for older Pillow versions try: text_width, text_height = draw.textsize(key, font=small_font) except: text_width, text_height = small_font.getsize(key) text_x = x + (KEYBOARD_SQUARE_SIZE - text_width) // 2 text_y = y + (KEYBOARD_SQUARE_SIZE - text_height) // 2 # White text on colored backgrounds (except unused) text_color = ( (255, 255, 255) if key_color != UNUSED_COLOR else TEXT_COLOR ) draw.text((text_x, text_y), key, fill=text_color, font=small_font) # Draw game status status_y = ( keyboard_start_y + keyboard_height + 10 if used_letters else start_y + board_height + 10 ) attempts_left = game.max_attempts - game.attempts status_text = ( f"Attempts: {game.attempts}/{game.max_attempts} ({attempts_left} left)" ) # Add game result if game is over if game.game_over: if game.won: status_text += f" - You won!" else: status_text += f" - Game over! The word was {game.word.upper()}." # Center the status text try: bbox = draw.textbbox((0, 0), status_text, font=status_font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] except AttributeError: # Fallback for older Pillow versions try: text_width, text_height = draw.textsize(status_text, font=status_font) except: text_width, text_height = status_font.getsize(status_text) text_x = (total_width - text_width) // 2 draw.text((text_x, status_y), status_text, fill=TEXT_COLOR, font=status_font) # Save image to a bytes buffer img_byte_arr = io.BytesIO() img.save(img_byte_arr, format="PNG") img_byte_arr.seek(0) return discord.File(fp=img_byte_arr, filename="wordle_board.png") class WordleView(ui.View): """Discord UI View for the Wordle game""" def __init__(self, player: discord.Member, word: str, timeout: float = 600.0): """ Initialize the Wordle game view Args: player: The Discord member playing the game word: The target word to guess timeout: Time in seconds before the view times out """ super().__init__(timeout=timeout) self.player = player self.game = WordleGame(word) self.message: Optional[discord.Message] = None self.used_letters: Set[str] = set() async def interaction_check(self, interaction: discord.Interaction) -> bool: """Ensure only the player can interact with the game""" if interaction.user.id != self.player.id: await interaction.response.send_message( "This is not your game!", ephemeral=True ) return False return True async def on_timeout(self) -> None: """Handle timeout - disable buttons and show the answer""" if self.message: self.game.game_over = True await self.update_message(None, timeout=True) # Track used letters when processing guesses def track_used_letters(self, guess: str) -> None: """Track letters that have been used in guesses""" for letter in guess: self.used_letters.add(letter) async def update_message( self, interaction: Optional[discord.Interaction] = None, timeout: bool = False ) -> None: """Update the game message with the current state""" if not self.message: return # Generate the board image board_image = generate_board_image(self.game, self.used_letters) # Create a simple text content content = f"# Wordle Game" # If game is over, add result to content if self.game.game_over: self.clear_items() # Remove all buttons if self.game.won: content += f"\n\n🎉 You won! The word was **{self.game.word.upper()}**." elif timeout: content += ( f"\n\n⏰ Time's up! The word was **{self.game.word.upper()}**." ) else: content += ( f"\n\n❌ Game over! The word was **{self.game.word.upper()}**." ) # Update the message with the image if interaction: await interaction.response.edit_message( content=content, attachments=[board_image], view=self ) else: await self.message.edit( content=content, attachments=[board_image], view=self ) @ui.button(label="Make a Guess", style=discord.ButtonStyle.primary) async def guess_button(self, interaction: discord.Interaction, _: ui.Button): """Button to make a guess""" # Create and send the modal modal = WordleGuessModal(self) await interaction.response.send_modal(modal) class WordleGuessModal(ui.Modal, title="Enter your guess"): """Modal for entering a Wordle guess""" guess = ui.TextInput( label="Your 5-letter guess", placeholder="Enter a 5-letter word", min_length=5, max_length=5, required=True, ) def __init__(self, view: WordleView): super().__init__() self.wordle_view = view async def on_submit(self, interaction: discord.Interaction): """Process the submitted guess""" guess = self.guess.value.strip().lower() # Validate the guess if len(guess) != 5: await interaction.response.send_message( "Please enter a 5-letter word.", ephemeral=True ) return if not guess.isalpha(): await interaction.response.send_message( "Your guess must contain only letters.", ephemeral=True ) return # Process the guess - this is the only place where make_guess should be called # to actually modify the game state self.wordle_view.game.make_guess(guess) # Track used letters for the keyboard display self.wordle_view.track_used_letters(guess) # Update the game message await self.wordle_view.update_message(interaction) def load_word_list( file_path: str = "words_alpha.txt", word_length: int = 5 ) -> List[str]: """ Load and filter words from a file Args: file_path: Path to the words file word_length: Length of words to filter (default: 5 for Wordle) Returns: List of words with the specified length """ try: with open(file_path, "r") as file: words = [word.strip().lower() for word in file if word.strip()] # Filter words by length filtered_words = [word for word in words if len(word) == word_length] return filtered_words except FileNotFoundError: print(f"Word list file not found: {file_path}") return []