2025-10-01 08:17:40 +00:00
|
|
|
#!/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"
|
|
|
|
|
2025-10-01 09:24:47 +00:00
|
|
|
class Pyrdle:
|
2025-10-01 08:17:40 +00:00
|
|
|
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"""
|
2025-10-01 14:43:31 +00:00
|
|
|
title = "PYRDLE - The Python Wordle Game"
|
2025-10-01 08:17:40 +00:00
|
|
|
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)
|
|
|
|
|
2025-10-01 09:24:47 +00:00
|
|
|
def draw_board(stdscr, game: Pyrdle, y_offset: int):
|
2025-10-01 08:17:40 +00:00
|
|
|
"""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):
|
2025-10-01 14:43:31 +00:00
|
|
|
# Draw a completed guess
|
2025-10-01 08:17:40 +00:00
|
|
|
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, "[ ]")
|
|
|
|
|
2025-10-01 09:24:47 +00:00
|
|
|
def draw_keyboard(stdscr, game: Pyrdle, y_offset: int):
|
2025-10-01 08:17:40 +00:00
|
|
|
"""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
|
2025-10-01 09:24:47 +00:00
|
|
|
game = Pyrdle()
|
2025-10-01 08:17:40 +00:00
|
|
|
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
|
2025-10-01 09:24:47 +00:00
|
|
|
game = Pyrdle()
|
2025-10-01 08:17:40 +00:00
|
|
|
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)
|