feat: Generate initial Wordle game board image for game start

This commit is contained in:
Slipstream 2025-05-20 20:03:06 -06:00
parent 65624a5670
commit dd334c5f24
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
2 changed files with 261 additions and 104 deletions

View File

@ -1,6 +1,9 @@
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"""
@ -91,6 +94,231 @@ class WordleGame:
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 = "dejavusans" # Directory specified by user
FONT_FILE_NAME = "DejaVuSans.ttf"
font_path = os.path.join(PROJECT_ROOT, FONT_DIR_NAME, FONT_FILE_NAME)
# 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"""
@ -122,122 +350,38 @@ class WordleView(ui.View):
self.game.game_over = True
await self.update_message(None, timeout=True)
def format_board(self) -> str:
"""Format the game board into a string representation"""
if not self.game.guesses:
# No guesses yet
return "No guesses yet. Use the button below to make a guess!"
board_lines = []
# Add each guess with colored squares
for guess in self.game.guesses:
# Use evaluate_guess instead of make_guess to avoid modifying game state
result = self.game.evaluate_guess(guess)
guess_line = ""
for item in result:
if item["status"] == "correct":
guess_line += f"🟩" # Green square for correct position
elif item["status"] == "present":
guess_line += f"🟨" # Yellow square for correct letter, wrong position
else:
guess_line += f"" # Black square for incorrect letter
# Track used letters
self.used_letters.add(item["letter"])
# Add the actual letters after the squares
guess_line += f" {guess.upper()}"
board_lines.append(guess_line)
return "\n".join(board_lines)
def format_keyboard(self) -> str:
"""Format a keyboard showing used letters"""
if not self.used_letters:
return ""
keyboard = [
"QWERTYUIOP",
"ASDFGHJKL",
"ZXCVBNM"
]
keyboard_lines = []
for row in keyboard:
line = ""
for letter in row:
letter_lower = letter.lower()
if letter_lower in self.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 self.game.guesses:
result = self.game.evaluate_guess(guess)
for i, char in enumerate(guess):
if char == letter_lower:
if result[i]["status"] == "correct":
correct_position = True
break
elif result[i]["status"] == "present":
present_position = True
if correct_position:
status = "correct"
elif present_position:
status = "present"
if status == "correct":
line += f"🟩" # Green for correct
elif status == "present":
line += f"🟨" # Yellow for present
else:
line += f"" # Black for absent
else:
line += f"" # White for unused
keyboard_lines.append(line)
return "\n".join(keyboard_lines)
# 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
# Format the message content
content = f"# Wordle Game\n\n"
content += self.format_board()
content += f"\n\n"
# Generate the board image
board_image = generate_board_image(self.game, self.used_letters)
# Add keyboard
keyboard = self.format_keyboard()
if keyboard:
content += f"{keyboard}\n\n"
# Create a simple text content
content = f"# Wordle Game"
# Add game status
attempts_left = self.game.max_attempts - self.game.attempts
content += f"Attempts: {self.game.attempts}/{self.game.max_attempts} ({attempts_left} left)\n"
# Add game result if game is over
# 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🎉 You won! The word was **{self.game.word.upper()}**."
content += f"\n\n🎉 You won! The word was **{self.game.word.upper()}**."
elif timeout:
content += f"\n⏰ Time's up! The word was **{self.game.word.upper()}**."
content += f"\n\n⏰ Time's up! The word was **{self.game.word.upper()}**."
else:
content += f"\n❌ Game over! The word was **{self.game.word.upper()}**."
content += f"\n\n❌ Game over! The word was **{self.game.word.upper()}**."
# Update the message
# Update the message with the image
if interaction:
await interaction.response.edit_message(content=content, view=self)
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
else:
await self.message.edit(content=content, view=self)
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):
@ -278,6 +422,9 @@ class WordleGuessModal(ui.Modal, title="Enter your guess"):
# 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)

View File

@ -303,9 +303,14 @@ class GamesCog(commands.Cog, name="Games"):
# Create the Wordle game view
view = WordleView(interaction.user, target_word)
# Send the initial game message
# Generate the initial board image
from .games.wordle_game import generate_board_image
initial_board_image = generate_board_image(view.game, view.used_letters)
# Send the initial game message with the image
await interaction.response.send_message(
"# Wordle Game\n\nGuess the 5-letter word. You have 6 attempts.\n\nNo guesses yet. Use the button below to make a guess!",
"# Wordle Game\n\nGuess the 5-letter word. You have 6 attempts.",
file=initial_board_image,
view=view
)
@ -766,9 +771,14 @@ class GamesCog(commands.Cog, name="Games"):
# Create the Wordle game view
view = WordleView(ctx.author, target_word)
# Send the initial game message
# Generate the initial board image
from .games.wordle_game import generate_board_image
initial_board_image = generate_board_image(view.game, view.used_letters)
# Send the initial game message with the image
message = await ctx.send(
"# Wordle Game\n\nGuess the 5-letter word. You have 6 attempts.\n\nNo guesses yet. Use the button below to make a guess!",
"# Wordle Game\n\nGuess the 5-letter word. You have 6 attempts.",
file=initial_board_image,
view=view
)