discordbot/cogs/games/wordle_game.py
2025-06-05 21:31:06 -06:00

518 lines
18 KiB
Python

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 []