"""Keyboard cooperative word game using Pygame. Players take turns selecting adjacent keys to form valid English words. """ from dataclasses import dataclass, field import logging from pathlib import Path import secrets import sys import pygame from python_pkg.keyboard_coop._dictionary import load_dictionary _logger = logging.getLogger(__name__) # Use cryptographically secure random number generator _rng = secrets.SystemRandom() # Initialize Pygame pygame.init() # Constants SCREEN_WIDTH = 1366 SCREEN_HEIGHT = 768 BACKGROUND_COLOR = (30, 30, 40) KEYBOARD_COLOR = (60, 60, 70) KEY_COLOR = (80, 80, 90) KEY_SELECTED_COLOR = (150, 150, 200) KEY_AVAILABLE_COLOR = (100, 150, 100) TEXT_COLOR = (255, 255, 255) PLAYER_COLORS = [(255, 100, 100), (100, 100, 255)] MIN_WORD_LENGTH = 3 # Keyboard layout KEYBOARD_LAYOUT = [ ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], ["a", "s", "d", "f", "g", "h", "j", "k", "l"], ["z", "x", "c", "v", "b", "n", "m"], ] # Key adjacency mapping KEY_ADJACENCY = { "q": ["w", "a", "s"], "w": ["q", "e", "a", "s", "d"], "e": ["w", "r", "s", "d", "f"], "r": ["e", "t", "d", "f", "g"], "t": ["r", "y", "f", "g", "h"], "y": ["t", "u", "g", "h", "j"], "u": ["y", "i", "h", "j", "k"], "i": ["u", "o", "j", "k", "l"], "o": ["i", "p", "k", "l"], "p": ["o", "l"], "a": ["q", "w", "s", "z", "x"], "s": ["q", "w", "e", "a", "d", "z", "x", "c"], "d": ["w", "e", "r", "s", "f", "x", "c", "v"], "f": ["e", "r", "t", "d", "g", "c", "v", "b"], "g": ["r", "t", "y", "f", "h", "v", "b", "n"], "h": ["t", "y", "u", "g", "j", "b", "n", "m"], "j": ["y", "u", "i", "h", "k", "n", "m"], "k": ["u", "i", "o", "j", "l", "m"], "l": ["i", "o", "p", "k"], "z": ["a", "s", "x"], "x": ["a", "s", "d", "z", "c"], "c": ["s", "d", "f", "x", "v"], "v": ["d", "f", "g", "c", "b"], "b": ["f", "g", "h", "v", "n"], "n": ["g", "h", "j", "b", "m"], "m": ["h", "j", "k", "n"], } @dataclass class GameState: """State for an ongoing keyboard coop game.""" current_player: int = 0 current_word: str = "" selected_letters: list[str] = field(default_factory=list) score: int = 0 game_over: bool = False message: str = "Player 1: Choose any letter to start!" @dataclass class KeyboardState: """State for the keyboard layout and adjacency.""" layout: list[list[str]] = field(default_factory=list) available_letters: set[str] = field(default_factory=set) adjacency: dict[str, list[str]] = field(default_factory=dict) positions: dict[str, pygame.Rect] = field(default_factory=dict) @dataclass class FontSet: """Collection of fonts used in the game.""" normal: pygame.font.Font large: pygame.font.Font small: pygame.font.Font class KeyboardCoopGame: """Main game class for the keyboard cooperative word game.""" def __init__(self) -> None: """Initialize the game window, fonts, and game state.""" self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Keyboard Coop Game") self.clock = pygame.time.Clock() self.fonts = FontSet( normal=pygame.font.Font(None, 24), large=pygame.font.Font(None, 32), small=pygame.font.Font(None, 20), ) # Load dictionary self.dictionary = load_dictionary(Path(__file__).parent) # Initialize game state self.state = GameState() # Initialize keyboard state self.keyboard = KeyboardState() # Generate random keyboard layout and adjacency self._generate_random_keyboard() def _generate_random_keyboard(self) -> None: """Generate a random keyboard layout and calculate adjacencies.""" # All 26 letters all_letters = list("abcdefghijklmnopqrstuvwxyz") _rng.shuffle(all_letters) # Create random layout with same structure as QWERTY (10-9-7) self.keyboard.layout = [ all_letters[0:10], # Top row: 10 keys all_letters[10:19], # Middle row: 9 keys all_letters[19:26], # Bottom row: 7 keys ] # Update available letters self.keyboard.available_letters = set(all_letters) # Calculate adjacencies based on new layout self._calculate_adjacencies() # Calculate key positions self.keyboard.positions = self._calculate_key_positions() def _calculate_adjacencies(self) -> None: """Calculate adjacencies based on current keyboard layout.""" self.keyboard.adjacency = {} for row_idx, row in enumerate(self.keyboard.layout): for col_idx, letter in enumerate(row): adjacents = [] # Check all 8 directions (including diagonals) directions = [ (-1, -1), (-1, 0), (-1, 1), # Above (0, -1), (0, 1), # Same row (1, -1), (1, 0), (1, 1), # Below ] for dr, dc in directions: new_row = row_idx + dr new_col = col_idx + dc # Check bounds if 0 <= new_row < len(self.keyboard.layout) and 0 <= new_col < len( self.keyboard.layout[new_row] ): adjacents.append(self.keyboard.layout[new_row][new_col]) self.keyboard.adjacency[letter] = adjacents def _calculate_key_positions(self) -> dict[str, pygame.Rect]: """Calculate the position of each key on screen.""" positions: dict[str, pygame.Rect] = {} key_width = 60 key_height = 60 key_spacing = 8 start_x = 50 start_y = 320 for row_idx, row in enumerate(self.keyboard.layout): row_offset = row_idx * 30 # Offset for layout for col_idx, key in enumerate(row): x = start_x + col_idx * (key_width + key_spacing) + row_offset y = start_y + row_idx * (key_height + key_spacing) positions[key] = pygame.Rect(x, y, key_width, key_height) return positions def _get_key_at_position(self, pos: tuple[int, int]) -> str | None: """Get the key at the given mouse position.""" for key, rect in self.keyboard.positions.items(): if rect.collidepoint(pos): return key return None def _is_valid_move(self, letter: str) -> bool: """Check if the letter is a valid move.""" if not self.state.selected_letters: return True # First move can be any letter last_letter = self.state.selected_letters[-1] return letter in self.keyboard.adjacency[last_letter] def _is_valid_word(self, word: str) -> bool: """Check if the word is in the dictionary.""" return word.lower() in self.dictionary def _calculate_score(self, word_length: int) -> int: """Calculate score exponentially based on word length.""" if word_length < MIN_WORD_LENGTH: return 0 return 2 ** (word_length - 2) def _handle_letter_click(self, letter: str) -> None: """Handle clicking on a letter.""" if letter in self.keyboard.available_letters and self._is_valid_move(letter): self.state.selected_letters.append(letter) self.state.current_word += letter # Update available letters to include adjacent letters AND the same letter adjacent_letters = ( set(self.keyboard.adjacency[letter]) if letter in self.keyboard.adjacency else set() ) adjacent_letters.add(letter) # Allow selecting the same letter again self.keyboard.available_letters = adjacent_letters # Switch player self.state.current_player = 1 - self.state.current_player self.state.message = ( f"Player {self.state.current_player + 1}: Choose an adjacent letter!" ) def _submit_word(self) -> None: """Submit the current word and check if it's valid.""" if len(self.state.current_word) >= MIN_WORD_LENGTH and self._is_valid_word( self.state.current_word ): points = self._calculate_score(len(self.state.current_word)) self.state.score += points self.state.message = ( f"'{self.state.current_word}' is valid! +{points} points " f"(Total: {self.state.score}) - New keyboard!" ) # Randomize keyboard layout after scoring self._generate_random_keyboard() elif len(self.state.current_word) < MIN_WORD_LENGTH: self.state.message = ( f"'{self.state.current_word}' is too short! " f"(minimum {MIN_WORD_LENGTH} letters)" ) else: self.state.message = f"'{self.state.current_word}' is not a valid word!" # Reset for next word self.state.current_word = "" self.state.selected_letters = [] self.state.current_player = 0 def _reset_game(self) -> None: """Reset the game to initial state.""" self.state = GameState() # Generate new random keyboard layout self._generate_random_keyboard() def _draw_keyboard(self) -> None: """Draw the virtual keyboard.""" for letter, rect in self.keyboard.positions.items(): # Determine key color if letter in self.state.selected_letters: color = KEY_SELECTED_COLOR elif letter in self.keyboard.available_letters: color = KEY_AVAILABLE_COLOR else: color = KEY_COLOR # Draw key pygame.draw.rect(self.screen, color, rect) pygame.draw.rect(self.screen, TEXT_COLOR, rect, 2) # Draw letter text = self.fonts.small.render( letter.upper(), antialias=True, color=TEXT_COLOR ) text_rect = text.get_rect(center=rect.center) self.screen.blit(text, text_rect) def _draw_text_line( self, text: str, pos: tuple[int, int], font: pygame.font.Font ) -> None: """Draw a single line of text at the given position.""" rendered = font.render(text, antialias=True, color=TEXT_COLOR) self.screen.blit(rendered, pos) def _draw_button(self, rect: pygame.Rect, label: str) -> None: """Draw a button with the given label.""" pygame.draw.rect(self.screen, KEY_COLOR, rect) pygame.draw.rect(self.screen, TEXT_COLOR, rect, 2) text = self.fonts.small.render(label, antialias=True, color=TEXT_COLOR) self.screen.blit(text, text.get_rect(center=rect.center)) def _draw_ui(self) -> tuple[pygame.Rect, pygame.Rect]: """Draw the user interface.""" # Title and status self._draw_text_line("Keyboard Coop Game", (30, 20), self.fonts.large) self._draw_text_line( f"Current Word: {self.state.current_word.upper()}", (30, 50), self.fonts.normal, ) self._draw_text_line(f"Score: {self.state.score}", (30, 75), self.fonts.normal) # Current player with color player_color = PLAYER_COLORS[self.state.current_player] player_text = self.fonts.normal.render( f"Current Player: {self.state.current_player + 1}", antialias=True, color=player_color, ) self.screen.blit(player_text, (30, 100)) # Message self._draw_text_line(self.state.message, (30, 125), self.fonts.normal) # Instructions instructions = [ "Instructions:", "• Take turns selecting adjacent letters", "• Click letters or use keyboard to select", "• Same letter can be selected consecutively", "• Press ENTER to submit word (min 3 letters)", "• Press R to reset game", "• Score increases exponentially", "• Keyboard randomizes after each valid word!", ] for idx, instruction in enumerate(instructions): self._draw_text_line(instruction, (750, 20 + idx * 22), self.fonts.small) # Buttons enter_rect = pygame.Rect(750, 190, 120, 40) reset_rect = pygame.Rect(880, 190, 120, 40) self._draw_button(enter_rect, "ENTER") self._draw_button(reset_rect, "RESET") return enter_rect, reset_rect def _handle_click(self, pos: tuple[int, int]) -> None: """Handle mouse clicks.""" # Check if clicked on a key key = self._get_key_at_position(pos) if key: self._handle_letter_click(key) # Check UI buttons enter_rect = pygame.Rect(750, 190, 120, 40) reset_rect = pygame.Rect(880, 190, 120, 40) if enter_rect.collidepoint(pos): self._submit_word() elif reset_rect.collidepoint(pos): self._reset_game() def run(self) -> None: """Main game loop.""" running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN: if event.button == 1: # Left click self._handle_click(event.pos) elif event.type == pygame.KEYDOWN: if event.key == pygame.K_RETURN: self._submit_word() elif event.key == pygame.K_r: self._reset_game() else: # Handle letter key presses key_name = pygame.key.name(event.key) if len(key_name) == 1 and key_name.isalpha(): self._handle_letter_click(key_name.lower()) # Clear screen self.screen.fill(BACKGROUND_COLOR) # Draw everything self._draw_keyboard() self._draw_ui() # Update display pygame.display.flip() self.clock.tick(60) pygame.quit() sys.exit() if __name__ == "__main__": game = KeyboardCoopGame() game.run()