pyrdle/pyrdle.py
2025-10-01 15:43:31 +01:00

428 lines
15 KiB
Python

#!/usr/bin/env python3
"""
pyrdle Game Implementation using ncurses
Supports multiple difficulty levels and word categories
"""
import argparse
import curses
import random
import sys
import os
from typing import List, Tuple
WORDLIST_DIR = "wordlists"
class Pyrdle:
def __init__(self):
self.word_list = []
self.target_word = ""
self.guesses = []
self.current_guess = ""
self.game_over = False
self.won = False
self.max_guesses = 6
self.word_length = 5
# Color pairs for the display
self.COLOR_DEFAULT = 1
self.COLOR_CORRECT = 2 # Green - letter in correct position
self.COLOR_PRESENT = 3 # Yellow - letter in word but wrong position
self.COLOR_ABSENT = 4 # Red - letter not in word (confirmed absent)
self.COLOR_WHITE = 5
self.COLOR_UNUSED = 6 # Gray - letter not yet used
# Keyboard layout for visual keyboard
self.keyboard_rows = [
"QWERTYUIOP",
"ASDFGHJKL",
"ZXCVBNM"
]
self.letter_status = {} # Track letter status for keyboard coloring
def load_words(self, filename: str = f"pyrdle_words.txt") -> bool:
"""Load word list from file"""
filepath = os.path.join(WORDLIST_DIR, filename)
try:
# Check if file exists
if not os.path.exists(filepath):
# Try with .txt extension if not provided
if not filepath.endswith('.txt'):
filepath = filepath + '.txt'
if not os.path.exists(filepath):
return False
with open(filepath, 'r') as f:
self.word_list = [word.strip().upper() for word in f.readlines()
if len(word.strip()) == self.word_length]
if not self.word_list:
return False
self.target_word = random.choice(self.word_list)
return True
except FileNotFoundError:
return False
def is_valid_word(self, word: str) -> bool:
"""Check if word exists in word list"""
return word.upper() in self.word_list
def check_guess(self, guess: str) -> List[int]:
"""
Check guess against target word
Returns list of color codes for each letter
"""
guess = guess.upper()
target = self.target_word
result = [self.COLOR_ABSENT] * self.word_length # Start with red (not in word)
target_chars = list(target)
# First pass: mark correct positions
for i in range(self.word_length):
if guess[i] == target[i]:
result[i] = self.COLOR_CORRECT
target_chars[i] = None
self.letter_status[guess[i]] = self.COLOR_CORRECT
# Second pass: mark present but wrong position
for i in range(self.word_length):
if result[i] == self.COLOR_ABSENT and guess[i] in target_chars:
result[i] = self.COLOR_PRESENT
target_chars[target_chars.index(guess[i])] = None
if guess[i] not in self.letter_status or \
self.letter_status[guess[i]] != self.COLOR_CORRECT:
self.letter_status[guess[i]] = self.COLOR_PRESENT
elif result[i] == self.COLOR_ABSENT:
# Letter is definitely not in the word - mark as red
if guess[i] not in self.letter_status:
self.letter_status[guess[i]] = self.COLOR_ABSENT
return result
def make_guess(self, guess: str) -> Tuple[bool, List[int]]:
"""
Process a guess
Returns (success, color_results)
"""
if len(guess) != self.word_length:
return False, []
if not self.is_valid_word(guess):
return False, []
colors = self.check_guess(guess)
self.guesses.append((guess.upper(), colors))
if guess.upper() == self.target_word:
self.game_over = True
self.won = True
elif len(self.guesses) >= self.max_guesses:
self.game_over = True
self.won = False
return True, colors
def draw_title(stdscr, y_offset: int, difficulty: str = ""):
"""Draw game title"""
title = "PYRDLE - The Python Wordle Game"
if difficulty:
title += f" [{difficulty}]"
height, width = stdscr.getmaxyx()
x = (width - len(title)) // 2
stdscr.attron(curses.A_BOLD)
stdscr.addstr(y_offset, x, title)
stdscr.attroff(curses.A_BOLD)
def draw_board(stdscr, game: Pyrdle, y_offset: int):
"""Draw the game board with all guesses"""
height, width = stdscr.getmaxyx()
board_x = (width - (game.word_length * 4 - 1)) // 2
for row in range(game.max_guesses):
y = y_offset + row * 2
if row < len(game.guesses):
# Draw a completed guess
guess, colors = game.guesses[row]
for col, (letter, color) in enumerate(zip(guess, colors)):
x = board_x + col * 4
stdscr.attron(curses.color_pair(color))
stdscr.addstr(y, x, f"[{letter}]")
stdscr.attroff(curses.color_pair(color))
elif row == len(game.guesses) and not game.game_over:
# Draw current guess
for col in range(game.word_length):
x = board_x + col * 4
if col < len(game.current_guess):
letter = game.current_guess[col]
else:
letter = " "
stdscr.attron(curses.color_pair(game.COLOR_WHITE))
stdscr.addstr(y, x, f"[{letter}]")
stdscr.attroff(curses.color_pair(game.COLOR_WHITE))
else:
# Draw empty slots
for col in range(game.word_length):
x = board_x + col * 4
stdscr.addstr(y, x, "[ ]")
def draw_keyboard(stdscr, game: Pyrdle, y_offset: int):
"""Draw visual keyboard showing letter status"""
height, width = stdscr.getmaxyx()
for row_idx, row in enumerate(game.keyboard_rows):
# Center each keyboard row
x = (width - (len(row) * 2 - 1)) // 2
y = y_offset + row_idx
# Add indentation for second and third rows
if row_idx == 1:
x += 1
elif row_idx == 2:
x += 3
for letter in row:
# Get color based on letter status
if letter in game.letter_status:
color = game.letter_status[letter]
else:
# Letter not yet used - show in gray/dim
color = game.COLOR_UNUSED
# Apply dim attribute for unused letters
if color == game.COLOR_UNUSED:
stdscr.attron(curses.A_DIM)
stdscr.addstr(y, x, letter)
stdscr.attroff(curses.A_DIM)
else:
stdscr.attron(curses.color_pair(color))
stdscr.addstr(y, x, letter)
stdscr.attroff(curses.color_pair(color))
x += 2
def draw_message(stdscr, message: str, y_offset: int, color_pair: int = 1):
"""Draw centered message"""
height, width = stdscr.getmaxyx()
x = (width - len(message)) // 2
stdscr.attron(curses.color_pair(color_pair))
stdscr.addstr(y_offset, x, message)
stdscr.attroff(curses.color_pair(color_pair))
def draw_instructions(stdscr, y_offset: int):
"""Draw game instructions"""
instructions = [
"Guess the 5-letter word in 6 tries!",
"",
"Colors: GREEN=Correct, YELLOW=Wrong position, RED=Not in word",
"",
"Type letters: A-Z",
"Enter: Submit guess",
"Backspace: Delete letter",
"Ctrl+C: Quit game"
]
height, width = stdscr.getmaxyx()
for i, line in enumerate(instructions):
if y_offset + i < height - 1:
x = (width - len(line)) // 2
stdscr.addstr(y_offset + i, x, line)
def parse_arguments():
"""Parse command line arguments for difficulty/category selection"""
parser = argparse.ArgumentParser(
description='pyrdle Game - Guess the 5-letter word!',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Difficulty Levels:
--easy Common everyday words (default)
--medium Standard vocabulary including less common words
--hard Challenging words including archaic and technical terms
Word Categories:
--common Common English words (default)
--literary Literary and archaic terms
--techy Technical and scientific terms
--cultural Words from various cultures and languages
--full Complete dictionary with all words
Examples:
python3 pyrdle.py --easy
python3 pyrdle.py --techy
python3 pyrdle.py --hard --literary
"""
)
# Difficulty levels (mutually exclusive)
difficulty = parser.add_mutually_exclusive_group()
difficulty.add_argument('--easy', action='store_true',
help='Easy mode: common words only')
difficulty.add_argument('--medium', action='store_true',
help='Medium mode: standard vocabulary')
difficulty.add_argument('--hard', action='store_true',
help='Hard mode: includes obscure words')
# Word categories (mutually exclusive)
category = parser.add_mutually_exclusive_group()
category.add_argument('--common', action='store_true',
help='Common everyday words')
category.add_argument('--literary', action='store_true',
help='Literary and archaic terms')
category.add_argument('--techy', action='store_true',
help='Technical and scientific terms')
category.add_argument('--cultural', action='store_true',
help='Cultural and international terms')
category.add_argument('--full', action='store_true',
help='Full dictionary (all words)')
# Custom word list
parser.add_argument('--wordlist', type=str, metavar='FILE',
help='Use custom word list file')
args = parser.parse_args()
# Determine which word list to use
if args.wordlist:
return args.wordlist, "CUSTOM"
# Map arguments to word list files
if args.techy:
return "pyrdle_words_techy.txt", "TECHNICAL"
elif args.literary:
return "pyrdle_words_literary.txt", "LITERARY"
elif args.cultural:
return "pyrdle_words_cultural.txt", "CULTURAL"
elif args.full:
return "pyrdle_words_full.txt", "FULL"
elif args.hard:
return "pyrdle_words_hard.txt", "HARD"
elif args.medium:
return "pyrdle_words_medium.txt", "MEDIUM"
else: # Default to easy/common
return "pyrdle_words_easy.txt", "EASY"
def main(stdscr):
"""Main game loop"""
# Parse command line arguments
word_file, difficulty_name = parse_arguments()
# Initialize curses
curses.curs_set(0) # Hide cursor
stdscr.clear()
# Initialize colors
curses.start_color()
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) # Default
curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN) # Correct position
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Wrong position
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_RED) # Not in word (confirmed absent)
curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_BLACK) # Current input
curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLACK) # Unused letters (gray/dim)
# Initialize game
game = Pyrdle()
if not game.load_words(word_file):
# Try fallback to default word list
if word_file != "pyrdle_words.txt" and game.load_words("pyrdle_words.txt"):
difficulty_name = "DEFAULT"
else:
stdscr.addstr(0, 0, f"Error: Could not load word list from {word_file}")
stdscr.addstr(1, 0, "Press any key to exit...")
stdscr.getch()
return
message = ""
message_color = 1
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
# Calculate vertical positions
y_pos = 1
# Draw components
draw_title(stdscr, y_pos, difficulty_name)
y_pos += 2
draw_board(stdscr, game, y_pos)
y_pos += game.max_guesses * 2 + 1
draw_keyboard(stdscr, game, y_pos)
y_pos += 4
# Draw message if exists
if message:
draw_message(stdscr, message, y_pos, message_color)
y_pos += 2
# Draw instructions at bottom
if y_pos + 6 < height:
draw_instructions(stdscr, y_pos)
# Show game over message
if game.game_over:
y_pos = height - 3 if height > 20 else y_pos
if game.won:
win_message = f"Congratulations! You won in {len(game.guesses)} guesses!"
draw_message(stdscr, win_message, y_pos, 2)
else:
lose_message = f"Game Over! The word was: {game.target_word}"
draw_message(stdscr, lose_message, y_pos, 4)
draw_message(stdscr, "Press 'N' for new game or 'Q' to quit", y_pos + 1, 1)
stdscr.refresh()
key = stdscr.getch()
if key in [ord('n'), ord('N')]:
# Start new game
game = Pyrdle()
game.load_words(word_file)
message = ""
continue
elif key in [ord('q'), ord('Q'), 27]: # 27 is ESC
break
continue
stdscr.refresh()
# Get input
key = stdscr.getch()
# Handle input
if key == 27: # ESC key
break
elif key == ord('\n'): # Enter key
if len(game.current_guess) == game.word_length:
success, _ = game.make_guess(game.current_guess)
if success:
game.current_guess = ""
message = ""
else:
message = "Not in word list!"
message_color = 4
else:
message = f"Word must be {game.word_length} letters!"
message_color = 4
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace
if game.current_guess:
game.current_guess = game.current_guess[:-1]
message = ""
elif ord('a') <= key <= ord('z'): # Lowercase letter
if len(game.current_guess) < game.word_length:
game.current_guess += chr(key).upper()
message = ""
elif ord('A') <= key <= ord('Z'): # Uppercase letter
if len(game.current_guess) < game.word_length:
game.current_guess += chr(key)
message = ""
if __name__ == "__main__":
# Handle help before initializing curses
if "--help" in sys.argv or "-h" in sys.argv:
# Parse arguments just to show help
word_file, difficulty = parse_arguments()
sys.exit(0)
try:
curses.wrapper(main)
except KeyboardInterrupt:
sys.exit(0)