452 lines
17 KiB
Python
452 lines
17 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! The word was {game.word.upper()}."
|
|
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.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 []
|