From 47028c95ae2443295eb264aca7e2591e7482a0a9 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Sun, 24 Aug 2025 22:13:00 +0200 Subject: [PATCH] Implement main menu, profile list, and profile stats screens; add screen manager and user profile integration --- profile_manager.py | 1260 ----------------- profile_manager/AmaticSC-Regular.ttf | Bin 0 -> 40000 bytes profile_manager/MIGRATION_GUIDE.md | 182 +++ profile_manager/profile_data.py | 282 ++++ profile_manager/profile_manager.py | 168 +++ profile_manager/score_api_client.py | 307 ++++ profile_manager/screens/README.md | 180 +++ profile_manager/screens/__init__.py | 24 + profile_manager/screens/base_screen.py | 91 ++ .../screens/create_profile_screen.py | 141 ++ .../screens/edit_profile_screen.py | 136 ++ profile_manager/screens/leaderboard_screen.py | 135 ++ profile_manager/screens/main_menu_screen.py | 103 ++ .../screens/profile_list_screen.py | 103 ++ .../screens/profile_stats_screen.py | 106 ++ profile_manager/screens/screen_manager.py | 142 ++ profile_manager/ui_components.py | 444 ++++++ profile_manager/user_profile_integration.py | 339 +++++ units/__pycache__/rat.cpython-313.pyc | Bin 10026 -> 10031 bytes units/__pycache__/unit.cpython-313.pyc | Bin 2954 -> 2959 bytes user_profiles.json | 12 +- 21 files changed, 2889 insertions(+), 1266 deletions(-) delete mode 100644 profile_manager.py create mode 100644 profile_manager/AmaticSC-Regular.ttf create mode 100644 profile_manager/MIGRATION_GUIDE.md create mode 100644 profile_manager/profile_data.py create mode 100644 profile_manager/profile_manager.py create mode 100644 profile_manager/score_api_client.py create mode 100644 profile_manager/screens/README.md create mode 100644 profile_manager/screens/__init__.py create mode 100644 profile_manager/screens/base_screen.py create mode 100644 profile_manager/screens/create_profile_screen.py create mode 100644 profile_manager/screens/edit_profile_screen.py create mode 100644 profile_manager/screens/leaderboard_screen.py create mode 100644 profile_manager/screens/main_menu_screen.py create mode 100644 profile_manager/screens/profile_list_screen.py create mode 100644 profile_manager/screens/profile_stats_screen.py create mode 100644 profile_manager/screens/screen_manager.py create mode 100644 profile_manager/ui_components.py create mode 100644 profile_manager/user_profile_integration.py diff --git a/profile_manager.py b/profile_manager.py deleted file mode 100644 index 029f6ee..0000000 --- a/profile_manager.py +++ /dev/null @@ -1,1260 +0,0 @@ -#!/usr/bin/env python3 -""" -User Profile Manager for Games -A PySDL2-based profile management system with gamepad-only controls - -Features: -- Create new user profiles -- Edit existing profiles -- Delete profiles -- Select active profile -- JSON-based storage -- Gamepad navigation only -""" - -import os -import json -import time -from typing import Dict, List, Optional, Any -from dataclasses import dataclass, asdict -from datetime import datetime - -import sdl2 -import sdl2.ext -from sdl2.ext.compat import byteify - -# Import the user profile integration system -from engine.user_profile_integration import UserProfileIntegration - - -@dataclass -class UserProfile: - """User profile data structure""" - name: str - created_date: str - last_played: str - games_played: int = 0 - total_score: int = 0 - best_score: int = 0 - settings: Dict[str, Any] = None - achievements: List[str] = None - - def __post_init__(self): - if self.settings is None: - self.settings = { - "difficulty": "normal", - "sound_volume": 50, - "music_volume": 50, - "screen_shake": True, - "auto_save": True - } - if self.achievements is None: - self.achievements = [] - - -class ProfileManager: - """Main profile management system""" - - def __init__(self, profiles_file: str = "user_profiles.json"): - self.profiles_file = profiles_file - self.profiles: Dict[str, UserProfile] = {} - self.active_profile: Optional[str] = None - - # Initialize user profile integration system - self.integration = UserProfileIntegration(profiles_file) - self.device_id = self.integration.get_device_id() - self.api_enabled = self.integration.api_enabled - - # UI State - self.current_screen = "main_menu" # main_menu, profile_list, create_profile, edit_profile, leaderboard, profile_stats - self.selected_index = 0 - self.input_text = "" - self.input_active = False - self.editing_field = None - - # Error dialog state - self.show_error = False - self.error_message = "" - self.error_timer = 0 - - # Leaderboard data - self.leaderboard_data = [] - self.leaderboard_type = "device" # "device" or "global" - - # Virtual keyboard state - self.vk_cursor_x = 0 # Virtual keyboard cursor X - self.vk_cursor_y = 0 # Virtual keyboard cursor Y - self.virtual_keyboard = [ - ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'], - ['K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'], - ['U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3'], - ['4', '5', '6', '7', '8', '9', ' ', '.', '-', '_'], - [' 0: - self.gamepad = sdl2.SDL_JoystickOpen(0) - print(f"Gamepad detected: {sdl2.SDL_JoystickName(self.gamepad).decode()}") - else: - print("No gamepad detected - using keyboard fallback") - - def load_profiles(self): - """Load profiles from JSON file""" - if os.path.exists(self.profiles_file): - try: - with open(self.profiles_file, 'r') as f: - data = json.load(f) - self.profiles = { - name: UserProfile(**profile_data) - for name, profile_data in data.get('profiles', {}).items() - } - self.active_profile = data.get('active_profile') - except (json.JSONDecodeError, KeyError) as e: - print(f"Error loading profiles: {e}") - self.profiles = {} - - def save_profiles(self): - """Save profiles to JSON file""" - data = { - 'profiles': { - name: asdict(profile) - for name, profile in self.profiles.items() - }, - 'active_profile': self.active_profile - } - - try: - with open(self.profiles_file, 'w') as f: - json.dump(data, f, indent=2) - except IOError as e: - print(f"Error saving profiles: {e}") - - def create_profile(self, name: str) -> bool: - """Create a new profile""" - if name in self.profiles or not name.strip(): - return False - - now = datetime.now().isoformat() - profile = UserProfile( - name=name.strip(), - created_date=now, - last_played=now - ) - - self.profiles[name.strip()] = profile - self.save_profiles() - - # Register with API server if available - if self.api_enabled: - result = self.integration.register_new_user(name.strip()) - if result: - print(f"Profile {name.strip()} registered with server") - else: - print(f"Warning: Profile {name.strip()} created locally but not registered with server") - - return True - - def delete_profile(self, name: str) -> bool: - """Delete a profile""" - if name in self.profiles: - del self.profiles[name] - if self.active_profile == name: - self.active_profile = None - self.save_profiles() - return True - return False - - def set_active_profile(self, name: str) -> bool: - """Set the active profile""" - if name in self.profiles: - self.active_profile = name - self.profiles[name].last_played = datetime.now().isoformat() - self.save_profiles() - - # Update integration system to load the new profile - self.integration.reload_profile() - - return True - return False - - def handle_gamepad_input(self): - """Handle gamepad input with debouncing""" - if not self.gamepad: - return - - current_time = time.time() - - # D-pad navigation - hat_state = sdl2.SDL_JoystickGetHat(self.gamepad, 0) - - # Up - if hat_state & sdl2.SDL_HAT_UP: - if self.can_process_input('up', current_time): - self.navigate_up() - - # Down - if hat_state & sdl2.SDL_HAT_DOWN: - if self.can_process_input('down', current_time): - self.navigate_down() - - # Left - if hat_state & sdl2.SDL_HAT_LEFT: - if self.can_process_input('left', current_time): - self.navigate_left() - - # Right - if hat_state & sdl2.SDL_HAT_RIGHT: - if self.can_process_input('right', current_time): - self.navigate_right() - - # Buttons - button_count = sdl2.SDL_JoystickNumButtons(self.gamepad) - for i in range(min(button_count, 16)): # Limit to reasonable number - if sdl2.SDL_JoystickGetButton(self.gamepad, i): - if self.can_process_input(f'button_{i}', current_time): - self.handle_button_press(i) - - def can_process_input(self, input_key: str, current_time: float) -> bool: - """Check if enough time has passed to process input (debouncing)""" - delay = 0.15 # 150ms delay - if input_key not in self.last_button_time: - self.last_button_time[input_key] = current_time - return True - - if current_time - self.last_button_time[input_key] > delay: - self.last_button_time[input_key] = current_time - return True - - return False - - def handle_button_press(self, button: int): - """Handle gamepad button presses""" - # If error dialog is shown, dismiss it on any button press - if self.show_error: - self.show_error = False - return - - # Button mappings (common gamepad layout) - # 0: A/Cross - Confirm - # 1: B/Circle - Back - # 2: X/Square - Delete/Special - # 3: Y/Triangle - Menu - - if button == 3: # A/Confirm - self.handle_confirm() - elif button == 4: # B/Back - self.handle_back() - elif button == 9: # X/Delete - self.handle_delete() - elif button == 10: # Y/Menu - self.handle_menu() - - def navigate_up(self): - """Navigate up in current screen""" - if self.show_error: - return - - if self.current_screen == "main_menu": - self.selected_index = max(0, self.selected_index - 1) - elif self.current_screen == "profile_list": - self.selected_index = max(0, self.selected_index - 1) - elif self.current_screen == "edit_profile": - self.selected_index = max(0, self.selected_index - 1) - elif self.current_screen == "create_profile" and self.input_active: - # Virtual keyboard navigation - self.vk_cursor_y = max(0, self.vk_cursor_y - 1) - # Adjust x cursor if current row is shorter - max_x = len(self.virtual_keyboard[self.vk_cursor_y]) - 1 - self.vk_cursor_x = min(self.vk_cursor_x, max_x) - - def navigate_down(self): - """Navigate down in current screen""" - if self.show_error: - return - - if self.current_screen == "main_menu": - max_index = 5 # Create, Select, Settings, Leaderboard, Stats, Exit (0-5) - self.selected_index = min(max_index, self.selected_index + 1) - elif self.current_screen == "profile_list": - max_index = len(self.profiles) # Profiles + Back - self.selected_index = min(max_index, self.selected_index + 1) - elif self.current_screen == "edit_profile": - max_index = 5 # 4 settings + save + back (0-5) - self.selected_index = min(max_index, self.selected_index + 1) - elif self.current_screen == "leaderboard": - max_index = 2 # Toggle type, refresh, back (0-2) - self.selected_index = min(max_index, self.selected_index + 1) - elif self.current_screen == "profile_stats": - max_index = 0 # Just back button - self.selected_index = min(max_index, self.selected_index + 1) - elif self.current_screen == "create_profile" and self.input_active: - # Virtual keyboard navigation - max_y = len(self.virtual_keyboard) - 1 - self.vk_cursor_y = min(max_y, self.vk_cursor_y + 1) - # Adjust x cursor if current row is shorter - max_x = len(self.virtual_keyboard[self.vk_cursor_y]) - 1 - self.vk_cursor_x = min(self.vk_cursor_x, max_x) - - def navigate_left(self): - """Navigate left (for adjusting values)""" - if self.show_error: - return - - if self.current_screen == "edit_profile": - self.adjust_setting(-1) - elif self.current_screen == "leaderboard": - # Toggle between device and global leaderboard - if self.selected_index == 0: - self.leaderboard_type = "global" if self.leaderboard_type == "device" else "device" - self.load_leaderboard_data() - elif self.current_screen == "create_profile" and self.input_active: - # Virtual keyboard navigation - self.vk_cursor_x = max(0, self.vk_cursor_x - 1) - - def navigate_right(self): - """Navigate right (for adjusting values)""" - if self.show_error: - return - - if self.current_screen == "edit_profile": - self.adjust_setting(1) - elif self.current_screen == "leaderboard": - # Toggle between device and global leaderboard - if self.selected_index == 0: - self.leaderboard_type = "global" if self.leaderboard_type == "device" else "device" - self.load_leaderboard_data() - elif self.current_screen == "create_profile" and self.input_active: - # Virtual keyboard navigation - max_x = len(self.virtual_keyboard[self.vk_cursor_y]) - 1 - self.vk_cursor_x = min(max_x, self.vk_cursor_x + 1) - - def handle_confirm(self): - """Handle confirm action (A button)""" - if self.show_error: - return - - if self.current_screen == "main_menu": - if self.selected_index == 0: # Create Profile - self.current_screen = "create_profile" - self.input_text = "" - self.input_active = True - elif self.selected_index == 1: # Select Profile - self.current_screen = "profile_list" - self.selected_index = 0 - elif self.selected_index == 2: # Settings - if self.active_profile: - self.current_screen = "edit_profile" - self.selected_index = 0 - elif self.selected_index == 3: # Leaderboard - if self.api_enabled: - self.current_screen = "leaderboard" - self.selected_index = 0 - self.load_leaderboard_data() - elif self.selected_index == 4: # Profile Stats - if self.active_profile: - self.current_screen = "profile_stats" - self.selected_index = 0 - elif self.selected_index == 5: # Exit - self.running = False - - elif self.current_screen == "profile_list": - profile_names = list(self.profiles.keys()) - if self.selected_index < len(profile_names): - # Select profile - profile_name = profile_names[self.selected_index] - self.set_active_profile(profile_name) - self.current_screen = "main_menu" - self.selected_index = 0 - else: # Back option - self.current_screen = "main_menu" - self.selected_index = 1 - - elif self.current_screen == "create_profile": - if self.input_active: - # Handle virtual keyboard selection - self.handle_virtual_keyboard_input() - else: - # Start text input mode - self.input_active = True - self.vk_cursor_x = 0 - self.vk_cursor_y = 0 - - elif self.current_screen == "edit_profile": - if self.selected_index == 4: # Save (index 4) - self.save_profiles() - self.current_screen = "main_menu" - self.selected_index = 0 - elif self.selected_index == 5: # Back (index 5) - self.current_screen = "main_menu" - self.selected_index = 0 - - elif self.current_screen == "leaderboard": - if self.selected_index == 1: # Refresh - self.load_leaderboard_data() - elif self.selected_index == 2: # Back - self.current_screen = "main_menu" - self.selected_index = 3 - - elif self.current_screen == "profile_stats": - if self.selected_index == 0: # Back - self.current_screen = "main_menu" - self.selected_index = 4 - - def handle_back(self): - """Handle back action (B button)""" - if self.show_error: - return - - if self.current_screen == "main_menu": - self.running = False - elif self.current_screen in ["profile_list", "create_profile", "edit_profile", "leaderboard", "profile_stats"]: - self.current_screen = "main_menu" - self.selected_index = 0 - self.input_active = False - - def handle_delete(self): - """Handle delete action (X button)""" - if self.show_error: - return - - if self.current_screen == "profile_list": - profile_names = list(self.profiles.keys()) - if self.selected_index < len(profile_names): - profile_name = profile_names[self.selected_index] - self.delete_profile(profile_name) - self.selected_index = min(self.selected_index, len(self.profiles) - 1) - elif self.current_screen == "create_profile": - # Delete character from input - if self.input_text: - self.input_text = self.input_text[:-1] - - def handle_menu(self): - """Handle menu action (Y button)""" - pass # Reserved for future use - - def handle_virtual_keyboard_input(self): - """Handle virtual keyboard character selection""" - if self.vk_cursor_y >= len(self.virtual_keyboard): - return - - row = self.virtual_keyboard[self.vk_cursor_y] - if self.vk_cursor_x >= len(row): - return - - selected_char = row[self.vk_cursor_x] - - if selected_char == ' 5: - color = sdl2.ext.Color(alpha, alpha, alpha * 2) - self.renderer.draw_line((0, y, 640, y), color) - - if self.current_screen == "main_menu": - self.render_main_menu() - elif self.current_screen == "profile_list": - self.render_profile_list() - elif self.current_screen == "create_profile": - self.render_create_profile() - elif self.current_screen == "edit_profile": - self.render_edit_profile() - elif self.current_screen == "leaderboard": - self.render_leaderboard() - elif self.current_screen == "profile_stats": - self.render_profile_stats() - - # Render error dialog on top if active - if self.show_error: - self.render_error_dialog() - # Auto-dismiss after timer expires - if time.time() > self.error_timer: - self.show_error = False - - self.renderer.present() - - def render_main_menu(self): - """Render main menu screen with improved layout""" - # Draw background gradient effect - self.renderer.fill((0, 0, 640, 120), self.colors['dark_gray']) - - # Title - title = "Profile Manager" - self.draw_text(title, 320, 20, self.colors['light_blue'], 'title', center=True) - - # Subtitle with active profile and API status - if self.active_profile: - active_text = f"Active: {self.active_profile}" - self.draw_text(active_text, 320, 60, self.colors['light_green'], 'medium', center=True) - else: - self.draw_text("No active profile", 320, 60, self.colors['yellow'], 'medium', center=True) - - # API connection status - api_status = "API: Connected" if self.api_enabled else "API: Offline" - api_color = self.colors['light_green'] if self.api_enabled else self.colors['red'] - self.draw_text(api_status, 320, 80, api_color, 'tiny', center=True) - - # Device ID display - device_text = f"Device: {self.device_id}" - self.draw_text(device_text, 320, 95, self.colors['light_gray'], 'tiny', center=True) - - # Menu panel - panel_x, panel_y = 120, 120 - panel_width, panel_height = 400, 240 - self.draw_panel(panel_x, panel_y, panel_width, panel_height, - self.colors['dark_gray'], self.colors['gray']) - - # Menu items with proper spacing - menu_items = [ - "Create Profile", - "Select Profile", - "Edit Settings", - "Leaderboard", - "Profile Stats", - "Exit" - ] - - button_width = 280 - button_height = 30 - button_x = panel_x + 60 - start_y = panel_y + 15 - - for i, item in enumerate(menu_items): - button_y = start_y + i * (button_height + 8) - selected = (i == self.selected_index) - - # Gray out unavailable options - if (item == "Edit Settings" or item == "Profile Stats") and not self.active_profile: - self.draw_button(f"{item} (No Profile)", button_x, button_y, button_width, button_height, False, disabled=True) - elif item == "Leaderboard" and not self.api_enabled: - self.draw_button(f"{item} (Offline)", button_x, button_y, button_width, button_height, False, disabled=True) - else: - self.draw_button(item, button_x, button_y, button_width, button_height, selected) - - # Controls help panel - help_panel_y = 380 - self.draw_panel(10, help_panel_y, 620, 80, - self.colors['black'], self.colors['dark_gray']) - - self.draw_text("Controls:", 20, help_panel_y + 8, - self.colors['light_blue'], 'small') - - self.draw_text("↑↓ Navigate Enter Confirm Escape Back", 20, help_panel_y + 30, - self.colors['light_gray'], 'tiny') - - def render_profile_list(self): - """Render profile selection screen with improved layout""" - # Header - self.renderer.fill((0, 0, 640, 70), self.colors['dark_gray']) - self.draw_text("Select Profile", 320, 15, self.colors['light_blue'], 'title', center=True) - - profile_names = list(self.profiles.keys()) - - if not profile_names: - # No profiles message - self.draw_panel(120, 100, 400, 150, self.colors['dark_gray'], self.colors['gray']) - self.draw_text("No profiles found", 320, 150, self.colors['yellow'], 'large', center=True) - self.draw_text("Create one first", 320, 180, - self.colors['light_gray'], 'medium', center=True) - - # Back button - self.draw_button("← Back", 270, 300, 100, 30, True) - else: - # Profile list panel - panel_height = min(280, len(profile_names) * 55 + 60) - self.draw_panel(30, 80, 580, panel_height, - self.colors['black'], self.colors['gray']) - - # Profile entries - for i, name in enumerate(profile_names): - profile = self.profiles[name] - - entry_y = 95 + i * 50 - entry_selected = (i == self.selected_index) - - # Profile entry background - entry_color = self.colors['blue'] if entry_selected else self.colors['dark_gray'] - border_color = self.colors['light_blue'] if entry_selected else self.colors['gray'] - self.draw_panel(40, entry_y, 560, 40, entry_color, border_color) - - # Profile name - name_color = self.colors['white'] if entry_selected else self.colors['light_gray'] - self.draw_text(name, 50, entry_y + 5, name_color, 'medium') - - # Profile stats (compact) - stats_text = f"Games: {profile.games_played} • Score: {profile.best_score}" - stats_color = self.colors['light_gray'] if entry_selected else self.colors['gray'] - self.draw_text(stats_text, 50, entry_y + 22, stats_color, 'tiny') - - # Active indicator - if name == self.active_profile: - self.draw_text("★", 580, entry_y + 12, - self.colors['light_green'], 'small') - - # Back button - back_y = 95 + len(profile_names) * 50 + 10 - back_selected = (self.selected_index == len(profile_names)) - self.draw_button("← Back", 270, back_y, 100, 30, back_selected) - - # Instructions - self.draw_panel(10, 420, 620, 40, self.colors['black'], self.colors['dark_gray']) - self.draw_text("Enter: Select • Escape: Back • Delete: Remove", - 320, 435, self.colors['light_gray'], 'tiny', center=True) - - def render_create_profile(self): - """Render profile creation screen with virtual keyboard""" - # Header - self.renderer.fill((0, 0, 640, 80), self.colors['dark_gray']) - self.draw_text("Create Profile", 320, 15, self.colors['light_blue'], 'title', center=True) - - # Input field - input_x, input_y = 120, 90 - input_width, input_height = 400, 30 - - input_bg = self.colors['white'] if self.input_active else self.colors['light_gray'] - input_border = self.colors['blue'] if self.input_active else self.colors['gray'] - self.draw_panel(input_x, input_y, input_width, input_height, input_bg, input_border, 2) - - # Input text - display_text = self.input_text if self.input_text else "Profile Name" - text_color = self.colors['black'] if self.input_text else self.colors['gray'] - self.draw_text(display_text, input_x + 10, input_y + 8, text_color, 'medium') - - if self.input_active: - # Virtual keyboard - self.render_virtual_keyboard() - else: - # Start input instruction - self.draw_text("Press Enter to start typing", 320, 150, - self.colors['yellow'], 'medium', center=True) - - # Back button - self.draw_button("← Back", 270, 200, 100, 30, True) - - # Instructions - if not self.input_active: - self.draw_panel(50, 400, 540, 60, self.colors['black'], self.colors['dark_gray']) - self.draw_text("Enter: Start Input • Escape: Back", 320, 420, - self.colors['light_gray'], 'small', center=True) - - def render_virtual_keyboard(self): - """Render the virtual keyboard""" - kb_start_x = 50 - kb_start_y = 150 - key_width = 45 - key_height = 30 - - for row_idx, row in enumerate(self.virtual_keyboard): - row_y = kb_start_y + row_idx * (key_height + 5) - - # Special handling for bottom row (commands) - if row_idx == len(self.virtual_keyboard) - 1: - # Bottom row with command keys - key_widths = [80, 80, 80, 80] # Wider keys for commands - x_offset = 140 # Center the bottom row - else: - key_widths = [key_width] * len(row) - x_offset = kb_start_x - - current_x = x_offset - for col_idx, char in enumerate(row): - selected = (row_idx == self.vk_cursor_y and col_idx == self.vk_cursor_x) - - # Key background - if selected: - self.draw_panel(current_x, row_y, key_widths[col_idx], key_height, - self.colors['blue'], self.colors['light_blue']) - text_color = self.colors['white'] - else: - self.draw_panel(current_x, row_y, key_widths[col_idx], key_height, - self.colors['dark_gray'], self.colors['gray']) - text_color = self.colors['light_gray'] - - # Key text - display_char = char - if char == ' 20: - player_name = player_name[:17] + "..." - self.draw_text(player_name, 120, entry_y, text_color, 'tiny') - - # Score - score = entry.get('best_score', 0) - self.draw_text(str(score), 350, entry_y, text_color, 'tiny') - - # Games - games = entry.get('total_games', 0) - self.draw_text(str(games), 450, entry_y, text_color, 'tiny') - - # Device (global leaderboard only) - if self.leaderboard_type == "global": - device = entry.get('device_id', '')[:8] - self.draw_text(device, 520, entry_y, text_color, 'tiny') - - # Instructions - self.draw_panel(10, 420, 620, 40, self.colors['black'], self.colors['dark_gray']) - self.draw_text("Left/Right: Toggle Type • Enter: Select • Escape: Back", - 320, 435, self.colors['light_gray'], 'tiny', center=True) - - def render_profile_stats(self): - """Render detailed profile statistics""" - if not self.active_profile: - self.current_screen = "main_menu" - return - - profile = self.profiles[self.active_profile] - - # Header - self.renderer.fill((0, 0, 640, 80), self.colors['dark_gray']) - title = f"Stats: {profile.name}" - self.draw_text(title, 320, 15, self.colors['light_blue'], 'title', center=True) - - # Get additional info from integration system - integration_info = self.integration.get_profile_info() - - # Main stats panel - self.draw_panel(50, 90, 540, 260, self.colors['dark_gray'], self.colors['gray']) - - # Basic stats - stats_y = 105 - line_height = 20 - - self.draw_text("Profile Statistics", 320, stats_y, self.colors['white'], 'large', center=True) - stats_y += 30 - - # Local stats - self.draw_text("Local Statistics:", 60, stats_y, self.colors['light_blue'], 'medium') - stats_y += line_height - - self.draw_text(f"Games Played: {profile.games_played}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - self.draw_text(f"Best Score: {profile.best_score}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - self.draw_text(f"Total Score: {profile.total_score}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - self.draw_text(f"Achievements: {len(profile.achievements)}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - # Creation and last played dates - try: - created = datetime.fromisoformat(profile.created_date).strftime("%Y-%m-%d") - last_played = datetime.fromisoformat(profile.last_played).strftime("%Y-%m-%d") - except: - created = "Unknown" - last_played = "Unknown" - - self.draw_text(f"Created: {created}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - self.draw_text(f"Last Played: {last_played}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - # Server stats (if available) - if self.api_enabled and integration_info: - stats_y += 10 - self.draw_text("Server Status:", 60, stats_y, self.colors['light_green'], 'medium') - stats_y += line_height - - self.draw_text(f"Device ID: {integration_info.get('device_id', 'Unknown')}", 70, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - self.draw_text("✓ Profile synced with server", 70, stats_y, self.colors['light_green'], 'small') - else: - stats_y += 10 - self.draw_text("Server Status:", 60, stats_y, self.colors['red'], 'medium') - stats_y += line_height - self.draw_text("✗ Server offline", 70, stats_y, self.colors['red'], 'small') - - # Settings summary - stats_y = 105 - settings_x = 350 - - self.draw_text("Current Settings:", settings_x, stats_y + 30, self.colors['light_blue'], 'medium') - stats_y += 50 - - difficulty = profile.settings.get('difficulty', 'normal').title() - self.draw_text(f"Difficulty: {difficulty}", settings_x + 10, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - sound_vol = profile.settings.get('sound_volume', 50) - self.draw_text(f"Sound Volume: {sound_vol}%", settings_x + 10, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - music_vol = profile.settings.get('music_volume', 50) - self.draw_text(f"Music Volume: {music_vol}%", settings_x + 10, stats_y, self.colors['light_gray'], 'small') - stats_y += line_height - - screen_shake = "On" if profile.settings.get('screen_shake', True) else "Off" - self.draw_text(f"Screen Shake: {screen_shake}", settings_x + 10, stats_y, self.colors['light_gray'], 'small') - - # Back button - back_selected = (self.selected_index == 0) - self.draw_button("← Back", 270, 370, 100, 30, back_selected) - - # Instructions - self.draw_panel(10, 420, 620, 40, self.colors['black'], self.colors['dark_gray']) - self.draw_text("Enter: Back • Escape: Back to Main Menu", - 320, 435, self.colors['light_gray'], 'tiny', center=True) - - def render_error_dialog(self): - """Render error dialog overlay""" - # Semi-transparent overlay - overlay_color = sdl2.ext.Color(0, 0, 0, 128) # Black with alpha - self.renderer.fill((0, 0, 640, 480), overlay_color) - - # Error dialog box - dialog_width = 400 - dialog_height = 120 - dialog_x = (640 - dialog_width) // 2 - dialog_y = (480 - dialog_height) // 2 - - # Dialog background with red border - self.draw_panel(dialog_x, dialog_y, dialog_width, dialog_height, - self.colors['black'], self.colors['red'], border_width=4) - - # Error title - self.draw_text("ERROR", dialog_x + dialog_width // 2, dialog_y + 20, - self.colors['red'], 'large', center=True) - - # Error message - self.draw_text(self.error_message, dialog_x + dialog_width // 2, dialog_y + 50, - self.colors['white'], 'medium', center=True) - - # Dismiss instruction - self.draw_text("Press any key to continue...", dialog_x + dialog_width // 2, dialog_y + 80, - self.colors['light_gray'], 'small', center=True) - - def draw_text(self, text: str, x: int, y: int, color, font_type='medium', center: bool = False): - """Draw text on screen with improved styling""" - if not text: - return - - factory = sdl2.ext.SpriteFactory(renderer=self.renderer) - font = self.fonts.get(font_type, self.fonts['medium']) - text_sprite = factory.from_text(text, color=color, fontmanager=font) - - if center: - x = x - text_sprite.size[0] // 2 - - text_sprite.position = (x, y) - self.renderer.copy(text_sprite, dstrect=text_sprite.position) - return text_sprite.size - - def draw_panel(self, x: int, y: int, width: int, height: int, bg_color=None, border_color=None, border_width=2): - """Draw a styled panel/box""" - if bg_color: - self.renderer.fill((x, y, width, height), bg_color) - - if border_color: - # Draw border - for i in range(border_width): - self.renderer.draw_rect((x + i, y + i, width - 2*i, height - 2*i), border_color) - - def draw_button(self, text: str, x: int, y: int, width: int, height: int, selected: bool = False, disabled: bool = False): - """Draw a styled button""" - # Button colors based on state - if disabled: - bg_color = self.colors['black'] - border_color = self.colors['dark_gray'] - text_color = self.colors['dark_gray'] - elif selected: - bg_color = self.colors['blue'] - border_color = self.colors['light_blue'] - text_color = self.colors['white'] - else: - bg_color = self.colors['dark_gray'] - border_color = self.colors['gray'] - text_color = self.colors['light_gray'] - - # Draw button background and border - self.draw_panel(x, y, width, height, bg_color, border_color) - - # Draw button text centered - text_x = x + width // 2 - text_y = y + height // 2 - 12 # Approximate text height offset - self.draw_text(text, text_x, text_y, text_color, 'medium', center=True) - - def handle_keyboard_input(self, event): - """Handle keyboard input for navigation only (no text input)""" - key = event.key.keysym.sym - - # If error dialog is shown, dismiss it on any key press - if self.show_error: - self.show_error = False - return - - # Navigation mode only - if key == sdl2.SDLK_UP: - self.navigate_up() - elif key == sdl2.SDLK_DOWN: - self.navigate_down() - elif key == sdl2.SDLK_LEFT: - self.navigate_left() - elif key == sdl2.SDLK_RIGHT: - self.navigate_right() - elif key == sdl2.SDLK_RETURN or key == sdl2.SDLK_SPACE: - self.handle_confirm() - elif key == sdl2.SDLK_ESCAPE: - self.handle_back() - elif key == sdl2.SDLK_DELETE or key == sdl2.SDLK_BACKSPACE: - self.handle_delete() - elif key == sdl2.SDLK_TAB: - self.handle_menu() - - def run(self): - """Main application loop""" - self.init_sdl() - - # Use SDL2's built-in timing instead of Clock - target_fps = 30 # Lower FPS for better performance on smaller screens - frame_time = 1000 // target_fps # milliseconds per frame - - while self.running: - events = sdl2.ext.get_events() - for event in events: - if event.type == sdl2.SDL_QUIT: - self.running = False - elif event.type == sdl2.SDL_KEYDOWN: - self.handle_keyboard_input(event) - - # Handle gamepad input (if available) - self.handle_gamepad_input() - - # Render frame - frame_start = sdl2.SDL_GetTicks() - self.render() - - # Cap framerate - frame_duration = sdl2.SDL_GetTicks() - frame_start - if frame_duration < frame_time: - sdl2.SDL_Delay(frame_time - frame_duration) - - # Cleanup - if self.gamepad: - sdl2.SDL_JoystickClose(self.gamepad) - sdl2.ext.quit() - - -def main(): - """Entry point""" - print("Starting Profile Manager with User Profile Integration...") - print("Features:") - print(" • Profile creation and management") - print(" • API server integration for leaderboards") - print(" • Device-specific and global leaderboards") - print(" • Detailed profile statistics") - print(" • Settings management") - print("") - print("Controls:") - print(" Arrow Keys: Navigate menus") - print(" Enter/Space: Confirm/Select") - print(" Escape: Back/Cancel") - print(" Delete/Backspace: Delete") - print(" Tab: Menu (reserved)") - print(" Left/Right: Adjust settings and toggle options") - print("") - print("Gamepad Controls (if connected):") - print(" D-Pad: Navigate menus") - print(" A Button: Confirm/Select") - print(" B Button: Back/Cancel") - print(" X Button: Delete") - print("") - - manager = ProfileManager() - print(f"Device ID: {manager.device_id}") - print(f"API Server: {'Connected' if manager.api_enabled else 'Offline'}") - print("") - - manager.run() - - -if __name__ == "__main__": - main() diff --git a/profile_manager/AmaticSC-Regular.ttf b/profile_manager/AmaticSC-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..90b86df1388e52656af29644857878864561dbac GIT binary patch literal 40000 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྿Uvp%7Fp(m?A0WfR$7v4&i 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 bool: + # Input handling logic + pass +``` + +2. **Register in screen manager**: +```python +# screens/screen_manager.py +"settings": SettingsScreen(self.data_manager, self.ui_renderer, self) +``` + +3. **Add navigation**: +```python +# From any screen +self.screen_manager.set_screen("settings") +``` + +## Backward Compatibility + +The migration maintains 100% backward compatibility: +- Same user interface +- Same keyboard/gamepad controls +- Same file formats (user_profiles.json) +- Same external API integration +- Same SDL2 rendering + +## Performance Impact + +The modular system has minimal performance impact: +- Slightly more memory for screen objects +- Faster rendering due to delegation +- Reduced complexity in main loop +- Better error isolation + +## Troubleshooting + +### ImportError: screens module +If you get import errors, ensure the `screens/` directory exists and has `__init__.py` + +### Screen not rendering +Check that the screen is properly registered in `ModularScreenManager._initialize_screens()` + +### Navigation issues +Verify screen transitions use `self.screen_manager.set_screen("screen_name")` + +## Development Workflow + +### Making Changes to Screens +1. Edit the specific screen module in `screens/` +2. No changes needed to main `profile_manager.py` +3. Test the individual screen + +### Adding Features +1. Determine which screen handles the feature +2. Modify only that screen module +3. Add any new dependencies to base class if needed + +### Debugging +1. Enable debug mode in individual screens +2. Test screens in isolation +3. Use screen-specific error handling + +The modular architecture makes the Profile Manager much more maintainable and extensible while preserving all existing functionality. diff --git a/profile_manager/profile_data.py b/profile_manager/profile_data.py new file mode 100644 index 0000000..ac4ba92 --- /dev/null +++ b/profile_manager/profile_data.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Profile Manager - Business Logic +Handles profile data management without UI dependencies +""" + +import os +import json +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, asdict +from datetime import datetime + +# Import the user profile integration system +from user_profile_integration import UserProfileIntegration + + +@dataclass +class UserProfile: + """User profile data structure""" + name: str + created_date: str + last_played: str + games_played: int = 0 + total_score: int = 0 + best_score: int = 0 + settings: Dict[str, Any] = None + achievements: List[str] = None + + def __post_init__(self): + if self.settings is None: + self.settings = { + "difficulty": "normal", + "sound_volume": 50, + "music_volume": 50, + "screen_shake": True, + "auto_save": True + } + if self.achievements is None: + self.achievements = [] + + +class ProfileDataManager: + """Core business logic for profile management""" + + def __init__(self, profiles_file: str = "user_profiles.json"): + self.profiles_file = profiles_file + self.profiles: Dict[str, UserProfile] = {} + self.active_profile: Optional[str] = None + + # Initialize user profile integration system + self.integration = UserProfileIntegration(profiles_file) + self.device_id = self.integration.get_device_id() + self.api_enabled = self.integration.api_enabled + + self.load_profiles() + + def load_profiles(self) -> bool: + """Load profiles from JSON file""" + if not os.path.exists(self.profiles_file): + return True + + try: + with open(self.profiles_file, 'r') as f: + data = json.load(f) + self.profiles = { + name: UserProfile(**profile_data) + for name, profile_data in data.get('profiles', {}).items() + } + self.active_profile = data.get('active_profile') + return True + except (json.JSONDecodeError, KeyError) as e: + print(f"Error loading profiles: {e}") + self.profiles = {} + return False + + def save_profiles(self) -> bool: + """Save profiles to JSON file""" + data = { + 'profiles': { + name: asdict(profile) + for name, profile in self.profiles.items() + }, + 'active_profile': self.active_profile + } + + try: + with open(self.profiles_file, 'w') as f: + json.dump(data, f, indent=2) + return True + except IOError as e: + print(f"Error saving profiles: {e}") + return False + + def create_profile(self, name: str) -> tuple[bool, str]: + """Create a new profile. Returns (success, message)""" + name = name.strip() + + if not name: + return False, "Profile name cannot be empty" + + if name in self.profiles: + return False, f"Profile '{name}' already exists" + + now = datetime.now().isoformat() + profile = UserProfile( + name=name, + created_date=now, + last_played=now + ) + + self.profiles[name] = profile + + if not self.save_profiles(): + del self.profiles[name] + return False, "Failed to save profile" + + # Register with API server if available + if self.api_enabled: + result = self.integration.register_new_user(name) + if result: + print(f"Profile {name} registered with server") + else: + print(f"Warning: Profile {name} created locally but not registered with server") + + return True, f"Profile '{name}' created successfully" + + def delete_profile(self, name: str) -> tuple[bool, str]: + """Delete a profile. Returns (success, message)""" + if name not in self.profiles: + return False, f"Profile '{name}' not found" + + del self.profiles[name] + + if self.active_profile == name: + self.active_profile = None + + if not self.save_profiles(): + return False, "Failed to save changes" + + return True, f"Profile '{name}' deleted" + + def set_active_profile(self, name: str) -> tuple[bool, str]: + """Set the active profile. Returns (success, message)""" + if name not in self.profiles: + return False, f"Profile '{name}' not found" + + self.active_profile = name + self.profiles[name].last_played = datetime.now().isoformat() + + if not self.save_profiles(): + return False, "Failed to save changes" + + # Update integration system to load the new profile + self.integration.reload_profile() + + return True, f"Active profile set to '{name}'" + + def get_profile_list(self) -> List[str]: + """Get list of profile names""" + return list(self.profiles.keys()) + + def get_profile(self, name: str) -> Optional[UserProfile]: + """Get a specific profile""" + return self.profiles.get(name) + + def get_active_profile(self) -> Optional[UserProfile]: + """Get the currently active profile""" + if self.active_profile: + return self.profiles.get(self.active_profile) + return None + + def update_profile_settings(self, name: str, setting: str, value: Any) -> tuple[bool, str]: + """Update a profile setting. Returns (success, message)""" + if name not in self.profiles: + return False, f"Profile '{name}' not found" + + profile = self.profiles[name] + + if setting not in profile.settings: + return False, f"Setting '{setting}' not found" + + # Validate setting values + if not self._validate_setting(setting, value): + return False, f"Invalid value for setting '{setting}'" + + profile.settings[setting] = value + + if not self.save_profiles(): + return False, "Failed to save changes" + + return True, f"Setting '{setting}' updated" + + def _validate_setting(self, setting: str, value: Any) -> bool: + """Validate setting values""" + validations = { + 'difficulty': lambda v: v in ['easy', 'normal', 'hard', 'expert'], + 'sound_volume': lambda v: isinstance(v, int) and 0 <= v <= 100, + 'music_volume': lambda v: isinstance(v, int) and 0 <= v <= 100, + 'screen_shake': lambda v: isinstance(v, bool), + 'auto_save': lambda v: isinstance(v, bool) + } + + validator = validations.get(setting) + return validator(value) if validator else True + + def get_leaderboard_data(self, leaderboard_type: str = "device") -> List[Dict[str, Any]]: + """Get leaderboard data""" + if not self.api_enabled: + return [] + + try: + if leaderboard_type == "device": + return self.integration.get_device_leaderboard(10) + else: # global + return self.integration.get_global_leaderboard(10) + except Exception as e: + print(f"Error loading leaderboard: {e}") + return [] + + def get_profile_stats(self, name: str) -> Optional[Dict[str, Any]]: + """Get detailed profile statistics""" + if name not in self.profiles: + return None + + profile = self.profiles[name] + integration_info = self.integration.get_profile_info() if self.api_enabled else {} + + try: + created = datetime.fromisoformat(profile.created_date).strftime("%Y-%m-%d") + last_played = datetime.fromisoformat(profile.last_played).strftime("%Y-%m-%d") + except: + created = "Unknown" + last_played = "Unknown" + + return { + 'profile': profile, + 'created_formatted': created, + 'last_played_formatted': last_played, + 'integration_info': integration_info, + 'api_enabled': self.api_enabled + } + + +class SettingsManager: + """Helper class for managing profile settings""" + + DIFFICULTY_OPTIONS = ["easy", "normal", "hard", "expert"] + VOLUME_RANGE = (0, 100, 5) # min, max, step + + @classmethod + def adjust_difficulty(cls, current: str, direction: int) -> str: + """Adjust difficulty setting""" + try: + current_index = cls.DIFFICULTY_OPTIONS.index(current) + new_index = (current_index + direction) % len(cls.DIFFICULTY_OPTIONS) + return cls.DIFFICULTY_OPTIONS[new_index] + except ValueError: + return "normal" + + @classmethod + def adjust_volume(cls, current: int, direction: int) -> int: + """Adjust volume setting""" + min_val, max_val, step = cls.VOLUME_RANGE + new_value = current + (direction * step) + return max(min_val, min(max_val, new_value)) + + @classmethod + def toggle_boolean(cls, current: bool) -> bool: + """Toggle boolean setting""" + return not current + + @classmethod + def get_setting_display_value(cls, setting: str, value: Any) -> str: + """Get display string for setting value""" + if setting == 'difficulty': + return value.title() + elif setting in ['sound_volume', 'music_volume']: + return f"{value}%" + elif setting in ['screen_shake', 'auto_save']: + return "On" if value else "Off" + else: + return str(value) diff --git a/profile_manager/profile_manager.py b/profile_manager/profile_manager.py new file mode 100644 index 0000000..cbdce82 --- /dev/null +++ b/profile_manager/profile_manager.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +User Profile Manager for Games +A PySDL2-based profile management system with gamepad-only controls +Refactored with separated UI and business logic components + +Features: +- Create new user profiles +- Edit existing profiles +- Delete profiles +- Select active profile +- JSON-based storage +- Gamepad navigation only +- Modular UI components +""" + +import time +from typing import Dict, Optional + +import sdl2 +import sdl2.ext + +# Import separated components +from ui_components import UIRenderer, GamepadInputHandler +from profile_data import ProfileDataManager, SettingsManager +from screens import ModularScreenManager + + +class ProfileManager: + """Main profile management system with separated UI and business logic""" + + def __init__(self, profiles_file: str = "user_profiles.json"): + # Business logic components + self.data_manager = ProfileDataManager(profiles_file) + + # Input handling + self.input_handler = GamepadInputHandler() + + # SDL2 components + self.window = None + self.renderer = None + self.ui_renderer = None + self.running = True + + # NEW: Modular screen manager (will be initialized after SDL setup) + self.screen_manager = None + + def init_sdl(self): + """Initialize SDL2 components""" + sdl2.ext.init(joystick=True) + + # Create window + self.window = sdl2.ext.Window( + title="Profile Manager", + size=(640, 480) + ) + self.window.show() + + # Create renderer + self.renderer = sdl2.ext.Renderer( + self.window, + flags=sdl2.SDL_RENDERER_ACCELERATED | sdl2.SDL_RENDERER_PRESENTVSYNC + ) + + # Initialize UI renderer + self.ui_renderer = UIRenderer(self.renderer, (640, 480)) + + # NEW: Initialize modular screen manager + self.screen_manager = ModularScreenManager(self.data_manager, self.ui_renderer) + + def handle_input(self): + """Handle all input (simplified with screen delegation)""" + # Handle SDL events for keyboard + events = sdl2.ext.get_events() + for event in events: + if event.type == sdl2.SDL_QUIT: + self.running = False + elif event.type == sdl2.SDL_KEYDOWN: + action = self._keyboard_to_action(event.key.keysym.sym) + if action: + result = self.screen_manager.handle_input(action) + if not result: + self.running = False + + # Handle gamepad input + gamepad_input = self.input_handler.get_gamepad_input() + for action, pressed in gamepad_input.items(): + if pressed: + gamepad_action = self._gamepad_to_action(action) + if gamepad_action: + result = self.screen_manager.handle_input(gamepad_action) + if not result: + self.running = False + + def _keyboard_to_action(self, key) -> str: + """Convert keyboard input to action""" + key_map = { + sdl2.SDLK_UP: 'up', + sdl2.SDLK_DOWN: 'down', + sdl2.SDLK_LEFT: 'left', + sdl2.SDLK_RIGHT: 'right', + sdl2.SDLK_RETURN: 'confirm', + sdl2.SDLK_SPACE: 'confirm', + sdl2.SDLK_ESCAPE: 'back', + sdl2.SDLK_DELETE: 'delete', + sdl2.SDLK_BACKSPACE: 'delete' + } + return key_map.get(key, '') + + def _gamepad_to_action(self, gamepad_action: str) -> str: + """Convert gamepad input to action""" + gamepad_map = { + 'up': 'up', + 'down': 'down', + 'left': 'left', + 'right': 'right', + 'button_3': 'confirm', # A/X button + 'button_4': 'back', # B/Circle button + 'button_9': 'delete' # X/Square button + } + return gamepad_map.get(gamepad_action, '') + + def render(self): + """Simplified rendering (delegated to screen manager)""" + # Clear and draw background + self.ui_renderer.clear_screen('black') + self.ui_renderer.draw_background_pattern('gradient') + + # Delegate all screen rendering to screen manager + self.screen_manager.render() + + self.ui_renderer.present() + + def run(self): + """Main application loop (unchanged)""" + self.init_sdl() + + target_fps = 30 + frame_time = 1000 // target_fps + + while self.running: + frame_start = sdl2.SDL_GetTicks() + + # Handle input + self.handle_input() + + # Render + self.render() + + # Frame timing + frame_elapsed = sdl2.SDL_GetTicks() - frame_start + if frame_elapsed < frame_time: + sdl2.SDL_Delay(frame_time - frame_elapsed) + + # Cleanup + self.input_handler.cleanup() + sdl2.ext.quit() + + +# Entry point +def main(): + """Main entry point""" + manager = ProfileManager() + manager.run() + + +if __name__ == "__main__": + main() diff --git a/profile_manager/score_api_client.py b/profile_manager/score_api_client.py new file mode 100644 index 0000000..4aca071 --- /dev/null +++ b/profile_manager/score_api_client.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Score API Client for Mice Game +Client module to integrate with the FastAPI score server +""" + +import requests +import json +from typing import Optional, List, Dict, Any +import time + + +class ScoreAPIClient: + """Client for communicating with the Mice Game Score API""" + + def __init__(self, api_base_url: str = "http://localhost:8000", timeout: int = 5): + """ + Initialize the API client + + Args: + api_base_url: Base URL of the API server + timeout: Request timeout in seconds + """ + self.api_base_url = api_base_url.rstrip('/') + self.timeout = timeout + + def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict[str, Any]]: + """ + Make HTTP request to API + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint + data: Request data for POST requests + + Returns: + Response JSON or None if error + """ + url = f"{self.api_base_url}{endpoint}" + + try: + if method.upper() == "GET": + response = requests.get(url, timeout=self.timeout) + elif method.upper() == "POST": + response = requests.post(url, json=data, timeout=self.timeout) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + if response.status_code == 200: + return response.json() + elif response.status_code in [400, 404, 409]: + # Client errors - return the error details + return {"error": True, "status": response.status_code, "detail": response.json()} + else: + return {"error": True, "status": response.status_code, "detail": "Server error"} + + except requests.exceptions.ConnectionError: + return {"error": True, "detail": "Could not connect to score server"} + except requests.exceptions.Timeout: + return {"error": True, "detail": "Request timeout"} + except Exception as e: + return {"error": True, "detail": str(e)} + + def signup_user(self, device_id: str, user_id: str) -> Dict[str, Any]: + """ + Register a new user + + Args: + device_id: Device identifier + user_id: User identifier + + Returns: + Response dictionary with success/error status + """ + endpoint = f"/signup/{device_id}/{user_id}" + response = self._make_request("POST", endpoint) + + if response is None: + return {"success": False, "message": "Failed to connect to server"} + + if response.get("error"): + return {"success": False, "message": response.get("detail", "Unknown error")} + + return response + + def submit_score(self, device_id: str, user_id: str, score: int, game_completed: bool = True) -> Dict[str, Any]: + """ + Submit a score for a user + + Args: + device_id: Device identifier + user_id: User identifier + score: Game score + game_completed: Whether the game was completed + + Returns: + Response dictionary with success/error status + """ + endpoint = f"/score/{device_id}/{user_id}" + data = { + "user_id": user_id, + "device_id": device_id, + "score": score, + "game_completed": game_completed + } + + response = self._make_request("POST", endpoint, data) + + if response is None: + return {"success": False, "message": "Failed to connect to server"} + + if response.get("error"): + return {"success": False, "message": response.get("detail", "Unknown error")} + + return response + + def get_device_users(self, device_id: str) -> List[Dict[str, Any]]: + """ + Get all users registered for a device + + Args: + device_id: Device identifier + + Returns: + List of user dictionaries + """ + endpoint = f"/users/{device_id}" + response = self._make_request("GET", endpoint) + + if response is None: + return [] + + # Check if it's an error response (dict with error field) or success (list) + if isinstance(response, dict) and response.get("error"): + return [] + + # If it's a list (successful response), return it + if isinstance(response, list): + return response + + return [] + + def get_user_scores(self, device_id: str, user_id: str, limit: int = 10) -> List[Dict[str, Any]]: + """ + Get recent scores for a user + + Args: + device_id: Device identifier + user_id: User identifier + limit: Maximum number of scores to return + + Returns: + List of score dictionaries + """ + endpoint = f"/scores/{device_id}/{user_id}?limit={limit}" + response = self._make_request("GET", endpoint) + + if response is None: + return [] + + # Check if it's an error response (dict with error field) or success (list) + if isinstance(response, dict) and response.get("error"): + return [] + + # If it's a list (successful response), return it + if isinstance(response, list): + return response + + return [] + + def get_leaderboard(self, device_id: str, limit: int = 10) -> List[Dict[str, Any]]: + """ + Get leaderboard for a device + + Args: + device_id: Device identifier + limit: Maximum number of entries to return + + Returns: + List of leaderboard entries + """ + endpoint = f"/leaderboard/{device_id}?limit={limit}" + response = self._make_request("GET", endpoint) + + if response is None: + return [] + + # Check if it's an error response (dict with error field) or success (list) + if isinstance(response, dict) and response.get("error"): + return [] + + # If it's a list (successful response), return it + if isinstance(response, list): + return response + + return [] + + def get_global_leaderboard(self, limit: int = 10) -> List[Dict[str, Any]]: + """ + Get global leaderboard across all devices + + Args: + limit: Maximum number of entries to return + + Returns: + List of global leaderboard entries + """ + endpoint = f"/leaderboard/global/top?limit={limit}" + response = self._make_request("GET", endpoint) + + if response is None: + return [] + + # Check if it's an error response (dict with error field) or success (list) + if isinstance(response, dict) and response.get("error"): + return [] + + # If it's a list (successful response), return it + if isinstance(response, list): + return response + + return [] + + def is_server_available(self) -> bool: + """ + Check if the API server is available + + Returns: + True if server is reachable, False otherwise + """ + response = self._make_request("GET", "/") + return response is not None and not response.get("error") + + def user_exists(self, device_id: str, user_id: str) -> bool: + """ + Check if a user is registered for a device + + Args: + device_id: Device identifier + user_id: User identifier + + Returns: + True if user exists, False otherwise + """ + users = self.get_device_users(device_id) + return any(user["user_id"] == user_id for user in users) + + +# Convenience functions for easy integration +def create_api_client(api_url: str = "http://localhost:8000") -> ScoreAPIClient: + """Create and return an API client instance""" + return ScoreAPIClient(api_url) + +def test_connection(api_url: str = "http://localhost:8000") -> bool: + """Test if the API server is available""" + client = ScoreAPIClient(api_url) + return client.is_server_available() + + +# Example usage and testing +if __name__ == "__main__": + # Example usage + print("Testing Score API Client...") + + # Create client + client = ScoreAPIClient() + + # Test server connection + if not client.is_server_available(): + print("ERROR: API server is not available. Start it with: python score_api.py") + exit(1) + + print("API server is available!") + + # Example device and user + device_id = "DEV-CLIENT01" + user_id = "ClientTestUser" + + # Test user signup + print(f"\nTesting user signup: {user_id}") + result = client.signup_user(device_id, user_id) + print(f"Signup result: {result}") + + # Test score submission + print(f"\nTesting score submission...") + result = client.submit_score(device_id, user_id, 1750, True) + print(f"Score submission result: {result}") + + # Test getting users + print(f"\nGetting users for device {device_id}:") + users = client.get_device_users(device_id) + for user in users: + print(f" User: {user['user_id']}, Best Score: {user['best_score']}") + + # Test getting user scores + print(f"\nGetting scores for {user_id}:") + scores = client.get_user_scores(device_id, user_id) + for score in scores: + print(f" Score: {score['score']}, Time: {score['timestamp']}") + + # Test leaderboard + print(f"\nLeaderboard for device {device_id}:") + leaderboard = client.get_leaderboard(device_id) + for entry in leaderboard: + print(f" Rank {entry['rank']}: {entry['user_id']} - {entry['best_score']} pts") + + print("\nClient testing completed!") diff --git a/profile_manager/screens/README.md b/profile_manager/screens/README.md new file mode 100644 index 0000000..c5669d6 --- /dev/null +++ b/profile_manager/screens/README.md @@ -0,0 +1,180 @@ +# Profile Manager Screen Modules + +This folder contains individual screen modules for the Profile Manager, providing a clean separation of concerns and modular architecture. + +## Structure + +``` +screens/ +├── __init__.py # Module exports +├── base_screen.py # Abstract base class for all screens +├── screen_manager.py # Manages screen modules and navigation +├── main_menu_screen.py # Main menu interface +├── profile_list_screen.py # Profile selection and management +├── create_profile_screen.py # Profile creation with virtual keyboard +├── edit_profile_screen.py # Profile settings editing +├── leaderboard_screen.py # Leaderboard display (device/global) +├── profile_stats_screen.py # Detailed profile statistics +└── README.md # This file +``` + +## Architecture + +### BaseScreen +- Abstract base class providing common interface +- Standard navigation methods (up, down, left, right) +- Input handling framework +- State management utilities + +### Screen Modules +Each screen is a self-contained module with: +- **Rendering**: Complete screen display logic +- **Input Handling**: Screen-specific input processing +- **State Management**: Internal state tracking +- **Navigation**: Transitions to other screens + +### ModularScreenManager +- Manages all screen module instances +- Handles screen transitions +- Provides error dialog overlay +- Maintains current screen state + +## Usage + +### Adding New Screens + +1. **Create Screen Module**: +```python +from .base_screen import BaseScreen + +class NewScreen(BaseScreen): + def render(self) -> None: + # Implement rendering logic + pass + + def handle_input(self, action: str) -> bool: + # Implement input handling + pass +``` + +2. **Register in ScreenManager**: +```python +# In screen_manager.py _initialize_screens() +"new_screen": NewScreen(self.data_manager, self.ui_renderer, self) +``` + +3. **Add to Exports**: +```python +# In __init__.py +from .new_screen import NewScreen +``` + +### Screen Integration + +To integrate with the main Profile Manager: + +```python +from screens import ModularScreenManager + +# Replace existing screen manager +self.screen_manager = ModularScreenManager(self.data_manager, self.ui_renderer) + +# Handle input +result = self.screen_manager.handle_input(action) + +# Render +self.screen_manager.render() +``` + +## Screen Responsibilities + +### MainMenuScreen +- Primary navigation hub +- Profile status display +- API connection status +- Menu option availability + +### ProfileListScreen +- Display existing profiles +- Profile selection +- Profile deletion +- Active profile indication + +### CreateProfileScreen +- Virtual keyboard interface +- Profile name input +- Input validation +- Profile creation + +### EditProfileScreen +- Settings adjustment interface +- Real-time value display +- Setting validation +- Changes persistence + +### LeaderboardScreen +- Leaderboard data display +- Device/Global toggle +- Data refresh functionality +- User highlighting + +### ProfileStatsScreen +- Comprehensive statistics +- Server sync status +- Settings summary +- Achievements display + +## Benefits + +### Modularity +- Each screen is independent and testable +- Clear separation of concerns +- Easy to add/remove/modify screens + +### Maintainability +- Isolated screen logic +- Consistent interface pattern +- Reduced coupling between screens + +### Extensibility +- Simple to add new screens +- Common functionality in base class +- Flexible navigation system + +### Testability +- Individual screen unit tests +- Mock dependencies easily +- Isolated functionality testing + +## Navigation Flow + +``` +Main Menu ─┬─ Create Profile + ├─ Profile List + ├─ Edit Profile (if profile active) + ├─ Leaderboard (if API enabled) + ├─ Profile Stats (if profile active) + └─ Exit + +All screens ─→ Back to Main Menu (ESC) +``` + +## Input Actions + +Standard actions handled by screens: +- `up`: Navigate selection up +- `down`: Navigate selection down +- `left`: Navigate left / Adjust setting +- `right`: Navigate right / Adjust setting +- `confirm`: Confirm selection / Enter +- `back`: Return to previous screen / ESC +- `delete`: Delete item / Backspace + +## Error Handling + +Screens can show error messages via: +```python +self.show_error("Error message") +``` + +The ModularScreenManager handles error dialog display and auto-dismissal. diff --git a/profile_manager/screens/__init__.py b/profile_manager/screens/__init__.py new file mode 100644 index 0000000..8fcd519 --- /dev/null +++ b/profile_manager/screens/__init__.py @@ -0,0 +1,24 @@ +""" +Screen modules for Profile Manager +Individual screen implementations with separated rendering and logic +""" + +from .base_screen import BaseScreen +from .main_menu_screen import MainMenuScreen +from .profile_list_screen import ProfileListScreen +from .create_profile_screen import CreateProfileScreen +from .edit_profile_screen import EditProfileScreen +from .leaderboard_screen import LeaderboardScreen +from .profile_stats_screen import ProfileStatsScreen +from .screen_manager import ModularScreenManager + +__all__ = [ + 'BaseScreen', + 'MainMenuScreen', + 'ProfileListScreen', + 'CreateProfileScreen', + 'EditProfileScreen', + 'LeaderboardScreen', + 'ProfileStatsScreen', + 'ModularScreenManager' +] diff --git a/profile_manager/screens/base_screen.py b/profile_manager/screens/base_screen.py new file mode 100644 index 0000000..8219a61 --- /dev/null +++ b/profile_manager/screens/base_screen.py @@ -0,0 +1,91 @@ +""" +Base Screen Class +Common interface and functionality for all screens in the Profile Manager +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, Tuple + + +class BaseScreen(ABC): + """Base class for all profile manager screens""" + + def __init__(self, data_manager, ui_renderer, screen_manager): + """ + Initialize base screen + + Args: + data_manager: ProfileDataManager instance + ui_renderer: UIRenderer instance + screen_manager: ScreenManager instance + """ + self.data_manager = data_manager + self.ui_renderer = ui_renderer + self.screen_manager = screen_manager + + # Screen-specific state + self.selected_index = 0 + self.screen_state = {} + + @abstractmethod + def render(self) -> None: + """Render the screen content""" + pass + + @abstractmethod + def handle_input(self, action: str) -> bool: + """ + Handle input for this screen + + Args: + action: Input action string ('up', 'down', 'left', 'right', 'confirm', 'back', 'delete') + + Returns: + bool: True if input was handled, False otherwise + """ + pass + + def navigate_up(self) -> None: + """Navigate selection up""" + if self.selected_index > 0: + self.selected_index -= 1 + + def navigate_down(self, max_index: int) -> None: + """Navigate selection down""" + if self.selected_index < max_index: + self.selected_index += 1 + + def navigate_left(self) -> None: + """Navigate left (screen-specific implementation)""" + pass + + def navigate_right(self) -> None: + """Navigate right (screen-specific implementation)""" + pass + + def handle_confirm(self) -> None: + """Handle confirm action (screen-specific implementation)""" + pass + + def handle_back(self) -> None: + """Handle back action (default: return to main menu)""" + self.screen_manager.set_screen("main_menu") + + def handle_delete(self) -> None: + """Handle delete action (screen-specific implementation)""" + pass + + def show_error(self, message: str) -> None: + """Show error message through the screen manager""" + # Delegate to screen manager + if hasattr(self.screen_manager, 'show_error_dialog'): + self.screen_manager.show_error_dialog(message) + + def reset_state(self) -> None: + """Reset screen state when entering""" + self.selected_index = 0 + self.screen_state.clear() + + def get_help_text(self) -> str: + """Get help text for this screen""" + return "↑↓ Navigate • Enter Confirm • Escape Back" diff --git a/profile_manager/screens/create_profile_screen.py b/profile_manager/screens/create_profile_screen.py new file mode 100644 index 0000000..97db527 --- /dev/null +++ b/profile_manager/screens/create_profile_screen.py @@ -0,0 +1,141 @@ +""" +Create Profile Screen +Virtual keyboard interface for creating new user profiles +""" + +from .base_screen import BaseScreen + + +class CreateProfileScreen(BaseScreen): + """Profile creation screen with virtual keyboard""" + + def __init__(self, data_manager, ui_renderer, screen_manager): + super().__init__(data_manager, ui_renderer, screen_manager) + + # Virtual keyboard layout + self.virtual_keyboard = [ + ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'], + ['K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'], + ['U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3'], + ['4', '5', '6', '7', '8', '9', ' ', '.', '-', '_'], + [' None: + """Render profile creation screen""" + self.ui_renderer.draw_header("Create Profile") + + # Input field + placeholder = "Profile Name" if not self.input_text else "" + self.ui_renderer.draw_input_field(self.input_text, 120, 90, 400, 30, + self.input_active, placeholder) + + if self.input_active: + # Virtual keyboard + self.ui_renderer.draw_virtual_keyboard(self.virtual_keyboard, + self.vk_cursor_x, self.vk_cursor_y) + help_text = "Arrows: Navigate • Enter: Select • Escape: Cancel" + else: + self.ui_renderer.draw_text("Press Enter to start typing", 320, 150, + 'yellow', 'medium', center=True) + self.ui_renderer.draw_button("← Back", 270, 200, 100, 30, True) + help_text = "Enter: Start Input • Escape: Back" + + self.ui_renderer.draw_footer_help(help_text) + + def handle_input(self, action: str) -> bool: + """Handle create profile input""" + if self.input_active: + # Virtual keyboard navigation + if action == 'up': + self.vk_cursor_y = max(0, self.vk_cursor_y - 1) + max_x = len(self.virtual_keyboard[self.vk_cursor_y]) - 1 + self.vk_cursor_x = min(self.vk_cursor_x, max_x) + return True + elif action == 'down': + max_y = len(self.virtual_keyboard) - 1 + self.vk_cursor_y = min(max_y, self.vk_cursor_y + 1) + max_x = len(self.virtual_keyboard[self.vk_cursor_y]) - 1 + self.vk_cursor_x = min(self.vk_cursor_x, max_x) + return True + elif action == 'left': + self.vk_cursor_x = max(0, self.vk_cursor_x - 1) + return True + elif action == 'right': + max_x = len(self.virtual_keyboard[self.vk_cursor_y]) - 1 + self.vk_cursor_x = min(max_x, self.vk_cursor_x + 1) + return True + elif action == 'confirm': + self.handle_virtual_keyboard_input() + return True + elif action == 'back': + self.input_active = False + return True + else: + # Initial screen navigation + if action == 'confirm': + self.input_active = True + self.vk_cursor_x = 0 + self.vk_cursor_y = 0 + return True + elif action == 'back': + self.handle_back() + return True + elif action == 'delete' and self.input_text: + self.input_text = self.input_text[:-1] + return True + + return False + + def handle_virtual_keyboard_input(self) -> None: + """Handle virtual keyboard character selection""" + if (self.vk_cursor_y >= len(self.virtual_keyboard) or + self.vk_cursor_x >= len(self.virtual_keyboard[self.vk_cursor_y])): + return + + selected_char = self.virtual_keyboard[self.vk_cursor_y][self.vk_cursor_x] + + if selected_char == ' None: + """Reset screen state when entering""" + super().reset_state() + self.input_text = "" + self.input_active = False + self.vk_cursor_x = 0 + self.vk_cursor_y = 0 + + def get_help_text(self) -> str: + """Get help text for create profile""" + if self.input_active: + return "Arrows: Navigate • Enter: Select • Escape: Cancel" + else: + return "Enter: Start Input • Escape: Back" diff --git a/profile_manager/screens/edit_profile_screen.py b/profile_manager/screens/edit_profile_screen.py new file mode 100644 index 0000000..732ccd5 --- /dev/null +++ b/profile_manager/screens/edit_profile_screen.py @@ -0,0 +1,136 @@ +""" +Edit Profile Screen +Interface for editing profile settings and configurations +""" + +from .base_screen import BaseScreen +from profile_data import SettingsManager + + +class EditProfileScreen(BaseScreen): + """Profile editing screen implementation""" + + def __init__(self, data_manager, ui_renderer, screen_manager): + super().__init__(data_manager, ui_renderer, screen_manager) + + # Settings configuration + self.settings = [ + ("Difficulty", "difficulty"), + ("Sound Vol", "sound_volume"), + ("Music Vol", "music_volume"), + ("Screen Shake", "screen_shake"), + ] + self.max_index = len(self.settings) + 1 # +1 for save/back buttons + + def render(self) -> None: + """Render profile editing screen""" + active_profile = self.data_manager.get_active_profile() + if not active_profile: + self.screen_manager.set_screen("main_menu") + return + + # Header with profile info + subtitle = f"Games: {active_profile.games_played} • Score: {active_profile.best_score}" + self.ui_renderer.draw_header(f"Edit: {active_profile.name}", subtitle) + + # Settings panel + self.ui_renderer.draw_panel(60, 90, 520, 280, 'dark_gray', 'gray') + self.ui_renderer.draw_text("Settings", 320, 105, 'white', 'large', center=True) + + # Settings items + for i, (label, setting_key) in enumerate(self.settings): + item_y = 130 + i * 35 + selected = (i == self.selected_index) + + if selected: + self.ui_renderer.draw_panel(80, item_y, 480, 25, 'blue', 'light_blue') + text_color = 'white' + value_color = 'light_green' + # Navigation arrows + self.ui_renderer.draw_text("◄", 70, item_y + 5, 'white', 'small') + self.ui_renderer.draw_text("►", 570, item_y + 5, 'white', 'small') + else: + text_color = 'light_gray' + value_color = 'yellow' + + # Setting label and value + self.ui_renderer.draw_text(label, 90, item_y + 5, text_color, 'medium') + + value = active_profile.settings[setting_key] + display_value = SettingsManager.get_setting_display_value(setting_key, value) + self.ui_renderer.draw_text(display_value, 500, item_y + 5, value_color, 'medium') + + # Action buttons + save_selected = (self.selected_index == len(self.settings)) + back_selected = (self.selected_index == len(self.settings) + 1) + + self.ui_renderer.draw_button("Save", 200, 310, 80, 30, save_selected) + self.ui_renderer.draw_button("← Back", 320, 310, 80, 30, back_selected) + + self.ui_renderer.draw_footer_help(self.get_help_text()) + + def handle_input(self, action: str) -> bool: + """Handle edit profile input""" + max_index = len(self.settings) + 1 # settings + save + back + + if action == 'up': + self.navigate_up() + return True + elif action == 'down': + self.navigate_down(max_index) + return True + elif action == 'left': + self.adjust_setting(-1) + return True + elif action == 'right': + self.adjust_setting(1) + return True + elif action == 'confirm': + self.handle_confirm() + return True + elif action == 'back': + self.handle_back() + return True + + return False + + def adjust_setting(self, direction: int) -> None: + """Adjust setting value left/right""" + if not self.data_manager.active_profile or self.selected_index >= len(self.settings): + return + + profile = self.data_manager.get_active_profile() + if not profile: + return + + setting_label, setting_name = self.settings[self.selected_index] + current_value = profile.settings[setting_name] + + # Adjust based on setting type + if setting_name == "difficulty": + new_value = SettingsManager.adjust_difficulty(current_value, direction) + elif setting_name in ["sound_volume", "music_volume"]: + new_value = SettingsManager.adjust_volume(current_value, direction) + elif setting_name == "screen_shake": + new_value = SettingsManager.toggle_boolean(current_value) + else: + return + + # Update the setting + success, message = self.data_manager.update_profile_settings( + self.data_manager.active_profile, setting_name, new_value + ) + + if not success: + self.show_error(message) + + def handle_confirm(self) -> None: + """Handle profile editing confirmation""" + if self.selected_index == len(self.settings): # Save + self.screen_manager.set_screen("main_menu") + elif self.selected_index == len(self.settings) + 1: # Back + self.screen_manager.set_screen("main_menu") + + def get_help_text(self) -> str: + """Get help text for edit profile""" + return "Left/Right: Adjust • Enter: Save/Back • Escape: Cancel" diff --git a/profile_manager/screens/leaderboard_screen.py b/profile_manager/screens/leaderboard_screen.py new file mode 100644 index 0000000..3328cb5 --- /dev/null +++ b/profile_manager/screens/leaderboard_screen.py @@ -0,0 +1,135 @@ +""" +Leaderboard Screen +Display global and device leaderboards with rankings and scores +""" + +from .base_screen import BaseScreen + + +class LeaderboardScreen(BaseScreen): + """Leaderboard display screen implementation""" + + def __init__(self, data_manager, ui_renderer, screen_manager): + super().__init__(data_manager, ui_renderer, screen_manager) + + # Leaderboard state + self.leaderboard_type = "device" # "device" or "global" + self.leaderboard_data = [] + + def render(self) -> None: + """Render leaderboard screen""" + title = f"{self.leaderboard_type.title()} Leaderboard" + self.ui_renderer.draw_header(title) + + if not self.data_manager.api_enabled: + self.ui_renderer.draw_text("Server offline - No leaderboard available", + 320, 150, 'red', 'medium', center=True) + self.ui_renderer.draw_button("← Back", 270, 200, 100, 30, True) + return + + # Controls + toggle_text = f"Type: {self.leaderboard_type.title()} (Left/Right to toggle)" + toggle_selected = (self.selected_index == 0) + self.ui_renderer.draw_button(toggle_text, 120, 90, 400, 25, toggle_selected) + + refresh_selected = (self.selected_index == 1) + back_selected = (self.selected_index == 2) + self.ui_renderer.draw_button("Refresh", 150, 125, 80, 25, refresh_selected) + self.ui_renderer.draw_button("← Back", 250, 125, 80, 25, back_selected) + + # Leaderboard data + if not self.leaderboard_data: + self.ui_renderer.draw_text("No leaderboard data available", 320, 200, + 'yellow', 'medium', center=True) + else: + self._render_leaderboard_data() + + self.ui_renderer.draw_footer_help(self.get_help_text()) + + def _render_leaderboard_data(self) -> None: + """Render the leaderboard data table""" + # Draw leaderboard entries + panel_height = min(250, len(self.leaderboard_data) * 25 + 40) + self.ui_renderer.draw_panel(50, 160, 540, panel_height, 'black', 'gray') + + # Headers + self.ui_renderer.draw_text("Rank", 60, 175, 'light_blue', 'small') + self.ui_renderer.draw_text("Player", 120, 175, 'light_blue', 'small') + self.ui_renderer.draw_text("Score", 350, 175, 'light_blue', 'small') + self.ui_renderer.draw_text("Games", 450, 175, 'light_blue', 'small') + + if self.leaderboard_type == "global": + self.ui_renderer.draw_text("Device", 520, 175, 'light_blue', 'small') + + # Entries + for i, entry in enumerate(self.leaderboard_data): + entry_y = 195 + i * 22 + + # Highlight current user + is_current = (self.data_manager.active_profile and + entry.get('user_id') == self.data_manager.active_profile) + text_color = 'light_green' if is_current else 'light_gray' + + self.ui_renderer.draw_text(str(entry.get('rank', i+1)), 60, entry_y, text_color, 'tiny') + + player_name = entry.get('user_id', 'Unknown')[:20] + self.ui_renderer.draw_text(player_name, 120, entry_y, text_color, 'tiny') + + self.ui_renderer.draw_text(str(entry.get('best_score', 0)), 350, entry_y, text_color, 'tiny') + self.ui_renderer.draw_text(str(entry.get('total_games', 0)), 450, entry_y, text_color, 'tiny') + + if self.leaderboard_type == "global": + device = entry.get('device_id', '')[:8] + self.ui_renderer.draw_text(device, 520, entry_y, text_color, 'tiny') + + def handle_input(self, action: str) -> bool: + """Handle leaderboard input""" + max_index = 2 # Toggle, Refresh, Back + + if action == 'up': + self.navigate_up() + return True + elif action == 'down': + self.navigate_down(max_index) + return True + elif action == 'left': + if self.selected_index == 0: # Toggle leaderboard type + self.toggle_leaderboard_type() + return True + elif action == 'right': + if self.selected_index == 0: # Toggle leaderboard type + self.toggle_leaderboard_type() + return True + elif action == 'confirm': + self.handle_confirm() + return True + elif action == 'back': + self.handle_back() + return True + + return False + + def toggle_leaderboard_type(self) -> None: + """Toggle between device and global leaderboard""" + self.leaderboard_type = "global" if self.leaderboard_type == "device" else "device" + self.load_leaderboard_data() + + def handle_confirm(self) -> None: + """Handle leaderboard actions""" + if self.selected_index == 1: # Refresh + self.load_leaderboard_data() + elif self.selected_index == 2: # Back + self.handle_back() + + def load_leaderboard_data(self) -> None: + """Load leaderboard data from data manager""" + self.leaderboard_data = self.data_manager.get_leaderboard_data(self.leaderboard_type) + + def reset_state(self) -> None: + """Reset screen state when entering""" + super().reset_state() + self.load_leaderboard_data() + + def get_help_text(self) -> str: + """Get help text for leaderboard""" + return "Left/Right: Toggle Type • Enter: Select • Escape: Back" diff --git a/profile_manager/screens/main_menu_screen.py b/profile_manager/screens/main_menu_screen.py new file mode 100644 index 0000000..d1fcfc9 --- /dev/null +++ b/profile_manager/screens/main_menu_screen.py @@ -0,0 +1,103 @@ +""" +Main Menu Screen +The primary menu interface for profile management +""" + +from .base_screen import BaseScreen + + +class MainMenuScreen(BaseScreen): + """Main menu screen implementation""" + + def __init__(self, data_manager, ui_renderer, screen_manager): + super().__init__(data_manager, ui_renderer, screen_manager) + self.menu_items = [ + "Create Profile", + "Select Profile", + "Edit Settings", + "Leaderboard", + "Profile Stats", + "Exit" + ] + + def render(self) -> None: + """Render main menu screen""" + # Header + active_profile = self.data_manager.active_profile + subtitle = f"Active: {active_profile}" if active_profile else "No active profile" + self.ui_renderer.draw_header("Profile Manager", subtitle) + + # API status + api_status = "API: Connected" if self.data_manager.api_enabled else "API: Offline" + api_color = 'light_green' if self.data_manager.api_enabled else 'red' + self.ui_renderer.draw_text(api_status, 320, 80, api_color, 'tiny', center=True) + + # Device ID + device_text = f"Device: {self.data_manager.device_id}" + self.ui_renderer.draw_text(device_text, 320, 95, 'light_gray', 'tiny', center=True) + + # Draw menu panel + self.ui_renderer.draw_panel(120, 120, 400, 240, 'dark_gray', 'gray') + + # Draw menu buttons + for i, item in enumerate(self.menu_items): + button_y = 135 + i * 38 + selected = (i == self.selected_index) + + # Gray out unavailable options + disabled = False + display_text = item + + if (item in ["Edit Settings", "Profile Stats"]) and not active_profile: + disabled = True + display_text = f"{item} (No Profile)" + elif item == "Leaderboard" and not self.data_manager.api_enabled: + disabled = True + display_text = f"{item} (Offline)" + + self.ui_renderer.draw_button(display_text, 180, button_y, 280, 30, + selected, disabled) + + # Help text + self.ui_renderer.draw_footer_help(self.get_help_text()) + + def handle_input(self, action: str) -> bool: + """Handle main menu input""" + if action == 'up': + self.navigate_up() + return True + elif action == 'down': + self.navigate_down(len(self.menu_items) - 1) + return True + elif action == 'confirm': + self.handle_confirm() + return True + elif action == 'back': + # Exit application from main menu + return False + + return False + + def handle_confirm(self) -> None: + """Handle main menu selections""" + index = self.selected_index + + if index == 0: # Create Profile + self.screen_manager.set_screen("create_profile") + elif index == 1: # Select Profile + self.screen_manager.set_screen("profile_list") + elif index == 2: # Settings + if self.data_manager.active_profile: + self.screen_manager.set_screen("edit_profile") + elif index == 3: # Leaderboard + if self.data_manager.api_enabled: + self.screen_manager.set_screen("leaderboard") + elif index == 4: # Profile Stats + if self.data_manager.active_profile: + self.screen_manager.set_screen("profile_stats") + elif index == 5: # Exit + return False + + def get_help_text(self) -> str: + """Get help text for main menu""" + return "↑↓ Navigate • Enter Confirm • Escape Exit" diff --git a/profile_manager/screens/profile_list_screen.py b/profile_manager/screens/profile_list_screen.py new file mode 100644 index 0000000..3c4cba0 --- /dev/null +++ b/profile_manager/screens/profile_list_screen.py @@ -0,0 +1,103 @@ +""" +Profile List Screen +Display and select from existing user profiles +""" + +from .base_screen import BaseScreen + + +class ProfileListScreen(BaseScreen): + """Profile list selection screen implementation""" + + def render(self) -> None: + """Render profile selection screen""" + self.ui_renderer.draw_header("Select Profile") + + profile_names = self.data_manager.get_profile_list() + + if not profile_names: + # No profiles message + self.ui_renderer.draw_panel(120, 100, 400, 150, 'dark_gray', 'gray') + self.ui_renderer.draw_text("No profiles found", 320, 150, 'yellow', 'large', center=True) + self.ui_renderer.draw_text("Create one first", 320, 180, 'light_gray', 'medium', center=True) + self.ui_renderer.draw_button("← Back", 270, 300, 100, 30, True) + else: + # Profile list + panel_height = min(280, len(profile_names) * 55 + 60) + self.ui_renderer.draw_panel(30, 80, 580, panel_height, 'black', 'gray') + + for i, name in enumerate(profile_names): + profile = self.data_manager.get_profile(name) + entry_y = 95 + i * 50 + selected = (i == self.selected_index) + + # Profile stats + stats_text = f"Games: {profile.games_played} • Score: {profile.best_score}" + indicator = "★" if name == self.data_manager.active_profile else "" + + self.ui_renderer.draw_list_item(name, 40, entry_y, 560, 40, + selected, stats_text, indicator) + + # Back button + back_y = 95 + len(profile_names) * 50 + 10 + back_selected = (self.selected_index == len(profile_names)) + self.ui_renderer.draw_button("← Back", 270, back_y, 100, 30, back_selected) + + self.ui_renderer.draw_footer_help(self.get_help_text()) + + def handle_input(self, action: str) -> bool: + """Handle profile list input""" + profile_names = self.data_manager.get_profile_list() + max_index = len(profile_names) # Includes back button + + if action == 'up': + self.navigate_up() + return True + elif action == 'down': + self.navigate_down(max_index) + return True + elif action == 'confirm': + self.handle_confirm() + return True + elif action == 'back': + self.handle_back() + return True + elif action == 'delete': + self.handle_delete() + return True + + return False + + def handle_confirm(self) -> None: + """Handle profile list selections""" + profile_names = self.data_manager.get_profile_list() + + if self.selected_index < len(profile_names): + # Select profile + profile_name = profile_names[self.selected_index] + success, message = self.data_manager.set_active_profile(profile_name) + if success: + self.screen_manager.set_screen("main_menu") + else: + self.show_error(message) + else: + # Back option + self.handle_back() + + def handle_delete(self) -> None: + """Handle profile deletion""" + profile_names = self.data_manager.get_profile_list() + + if self.selected_index < len(profile_names): + profile_name = profile_names[self.selected_index] + success, message = self.data_manager.delete_profile(profile_name) + if not success: + self.show_error(message) + else: + # Adjust selection if needed + max_index = max(0, len(self.data_manager.get_profile_list()) - 1) + self.selected_index = min(self.selected_index, max_index) + + def get_help_text(self) -> str: + """Get help text for profile list""" + return "Enter: Select • Escape: Back • Delete: Remove" diff --git a/profile_manager/screens/profile_stats_screen.py b/profile_manager/screens/profile_stats_screen.py new file mode 100644 index 0000000..9c47698 --- /dev/null +++ b/profile_manager/screens/profile_stats_screen.py @@ -0,0 +1,106 @@ +""" +Profile Stats Screen +Display detailed statistics for the active user profile +""" + +from .base_screen import BaseScreen + + +class ProfileStatsScreen(BaseScreen): + """Profile statistics display screen implementation""" + + def render(self) -> None: + """Render detailed profile statistics""" + if not self.data_manager.active_profile: + self.screen_manager.set_screen("main_menu") + return + + stats_data = self.data_manager.get_profile_stats(self.data_manager.active_profile) + if not stats_data: + self.screen_manager.set_screen("main_menu") + return + + profile = stats_data['profile'] + + # Header + self.ui_renderer.draw_header(f"Stats: {profile.name}") + + # Stats panel + self.ui_renderer.draw_panel(50, 90, 540, 260, 'dark_gray', 'gray') + + # Render statistics content + self._render_local_statistics(stats_data, profile) + self._render_server_status(stats_data) + self._render_settings_summary(profile) + + # Back button + back_selected = (self.selected_index == 0) + self.ui_renderer.draw_button("← Back", 270, 370, 100, 30, back_selected) + + self.ui_renderer.draw_footer_help(self.get_help_text()) + + def _render_local_statistics(self, stats_data, profile) -> None: + """Render local profile statistics""" + y = 110 + self.ui_renderer.draw_text("Local Statistics:", 60, y, 'light_blue', 'medium') + y += 25 + + stats = [ + f"Games Played: {profile.games_played}", + f"Best Score: {profile.best_score}", + f"Total Score: {profile.total_score}", + f"Achievements: {len(profile.achievements)}", + f"Created: {stats_data['created_formatted']}", + f"Last Played: {stats_data['last_played_formatted']}" + ] + + for stat in stats: + self.ui_renderer.draw_text(stat, 70, y, 'light_gray', 'small') + y += 20 + + def _render_server_status(self, stats_data) -> None: + """Render server connection and sync status""" + y = 250 + + if stats_data['api_enabled']: + self.ui_renderer.draw_text("Server Status:", 60, y, 'light_green', 'medium') + y += 20 + integration_info = stats_data['integration_info'] + device_id = integration_info.get('device_id', 'Unknown') + self.ui_renderer.draw_text(f"Device ID: {device_id}", 70, y, 'light_gray', 'small') + y += 20 + self.ui_renderer.draw_text("✓ Profile synced with server", 70, y, 'light_green', 'small') + else: + self.ui_renderer.draw_text("Server Status:", 60, y, 'red', 'medium') + y += 20 + self.ui_renderer.draw_text("✗ Server offline", 70, y, 'red', 'small') + + def _render_settings_summary(self, profile) -> None: + """Render current profile settings summary""" + settings_x = 350 + y = 110 + self.ui_renderer.draw_text("Current Settings:", settings_x, y, 'light_blue', 'medium') + y += 25 + + settings_display = [ + f"Difficulty: {profile.settings.get('difficulty', 'normal').title()}", + f"Sound Volume: {profile.settings.get('sound_volume', 50)}%", + f"Music Volume: {profile.settings.get('music_volume', 50)}%", + f"Screen Shake: {'On' if profile.settings.get('screen_shake', True) else 'Off'}" + ] + + for setting in settings_display: + self.ui_renderer.draw_text(setting, settings_x + 10, y, 'light_gray', 'small') + y += 20 + + def handle_input(self, action: str) -> bool: + """Handle profile stats input""" + if action == 'confirm' or action == 'back': + self.handle_back() + return True + + return False + + def get_help_text(self) -> str: + """Get help text for profile stats""" + return "Enter: Back • Escape: Back to Main Menu" diff --git a/profile_manager/screens/screen_manager.py b/profile_manager/screens/screen_manager.py new file mode 100644 index 0000000..98e0b61 --- /dev/null +++ b/profile_manager/screens/screen_manager.py @@ -0,0 +1,142 @@ +""" +Screen Manager Module +Manages individual screen modules and navigation between them +""" + +from typing import Dict, Optional +from screens import ( + BaseScreen, + MainMenuScreen, + ProfileListScreen, + CreateProfileScreen, + EditProfileScreen, + LeaderboardScreen, + ProfileStatsScreen +) + + +class ModularScreenManager: + """Manages individual screen modules and navigation""" + + def __init__(self, data_manager, ui_renderer): + """ + Initialize screen manager with all screen modules + + Args: + data_manager: ProfileDataManager instance + ui_renderer: UIRenderer instance + """ + self.data_manager = data_manager + self.ui_renderer = ui_renderer + + # Current screen state + self.current_screen_name = "main_menu" + self.screens: Dict[str, BaseScreen] = {} + + # Initialize all screen modules + self._initialize_screens() + + # Error state + self.show_error = False + self.error_message = "" + self.error_timer = 0 + + def _initialize_screens(self) -> None: + """Initialize all screen module instances""" + self.screens = { + "main_menu": MainMenuScreen(self.data_manager, self.ui_renderer, self), + "profile_list": ProfileListScreen(self.data_manager, self.ui_renderer, self), + "create_profile": CreateProfileScreen(self.data_manager, self.ui_renderer, self), + "edit_profile": EditProfileScreen(self.data_manager, self.ui_renderer, self), + "leaderboard": LeaderboardScreen(self.data_manager, self.ui_renderer, self), + "profile_stats": ProfileStatsScreen(self.data_manager, self.ui_renderer, self) + } + + def set_screen(self, screen_name: str) -> None: + """ + Switch to a different screen + + Args: + screen_name: Name of the screen to switch to + """ + if screen_name in self.screens: + self.current_screen_name = screen_name + # Reset state when entering new screen + self.screens[screen_name].reset_state() + else: + print(f"Warning: Unknown screen '{screen_name}'") + + def get_current_screen(self) -> Optional[BaseScreen]: + """Get the current active screen module""" + return self.screens.get(self.current_screen_name) + + def handle_input(self, action: str) -> bool: + """ + Handle input by delegating to current screen + + Args: + action: Input action string + + Returns: + bool: True if input was handled and app should continue, False to exit + """ + # Dismiss error dialog on any input + if self.show_error: + self.show_error = False + return True + + current_screen = self.get_current_screen() + if current_screen: + result = current_screen.handle_input(action) + + # Handle special case for main menu exit + if self.current_screen_name == "main_menu" and action == 'back': + return False # Exit application + + return result if result is not None else True + + return True + + def render(self) -> None: + """Render current screen and any overlays""" + current_screen = self.get_current_screen() + if current_screen: + current_screen.render() + + # Render error dialog if active + if self.show_error: + import time + self.ui_renderer.draw_error_dialog(self.error_message) + # Auto-dismiss after timer + if time.time() > self.error_timer: + self.show_error = False + + def show_error_dialog(self, message: str) -> None: + """ + Show an error dialog overlay + + Args: + message: Error message to display + """ + import time + self.show_error = True + self.error_message = message + self.error_timer = time.time() + 3.0 + + @property + def current_screen(self) -> str: + """Get current screen name (for compatibility)""" + return self.current_screen_name + + @property + def selected_index(self) -> int: + """Get selected index from current screen (for compatibility)""" + current_screen = self.get_current_screen() + return current_screen.selected_index if current_screen else 0 + + @selected_index.setter + def selected_index(self, value: int) -> None: + """Set selected index on current screen (for compatibility)""" + current_screen = self.get_current_screen() + if current_screen: + current_screen.selected_index = value diff --git a/profile_manager/ui_components.py b/profile_manager/ui_components.py new file mode 100644 index 0000000..118f751 --- /dev/null +++ b/profile_manager/ui_components.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +""" +Reusable UI Components for SDL2-based applications +Provides common graphics operations and UI elements +""" + +import os +import time +from typing import Dict, List, Optional, Tuple, Any +import sdl2 +import sdl2.ext + + +class UIColors: + """Standard color palette for UI components""" + + def __init__(self): + self.colors = { + 'white': sdl2.ext.Color(255, 255, 255), + 'black': sdl2.ext.Color(0, 0, 0), + 'gray': sdl2.ext.Color(128, 128, 128), + 'light_gray': sdl2.ext.Color(200, 200, 200), + 'dark_gray': sdl2.ext.Color(64, 64, 64), + 'blue': sdl2.ext.Color(70, 130, 200), + 'light_blue': sdl2.ext.Color(120, 180, 255), + 'green': sdl2.ext.Color(50, 200, 50), + 'light_green': sdl2.ext.Color(100, 255, 100), + 'red': sdl2.ext.Color(200, 50, 50), + 'yellow': sdl2.ext.Color(255, 220, 0), + 'orange': sdl2.ext.Color(255, 165, 0), + 'purple': sdl2.ext.Color(150, 50, 200) + } + + def get(self, color_name: str): + """Get color by name""" + return self.colors.get(color_name, self.colors['white']) + + +class FontManager: + """Manages multiple font sizes for the application""" + + def __init__(self, font_path: Optional[str] = None): + self.font_path = font_path or self._find_default_font() + self.fonts = {} + self._initialize_fonts() + + def _find_default_font(self) -> Optional[str]: + """Find a suitable default font""" + font_paths = [ + "assets/decterm.ttf", + "./assets/terminal.ttf", + "./assets/AmaticSC-Regular.ttf" + ] + + for path in font_paths: + if os.path.exists(path): + return path + return None + + def _initialize_fonts(self): + """Initialize font managers for different sizes""" + sizes = { + 'title': 36, + 'large': 28, + 'medium': 22, + 'small': 18, + 'tiny': 14 + } + + for name, size in sizes.items(): + self.fonts[name] = sdl2.ext.FontManager( + font_path=self.font_path, + size=size + ) + + def get(self, size: str): + """Get font manager by size name""" + return self.fonts.get(size, self.fonts['medium']) + + +class UIRenderer: + """Main UI rendering class with reusable drawing operations""" + + def __init__(self, renderer: sdl2.ext.Renderer, window_size: Tuple[int, int] = (640, 480)): + self.renderer = renderer + self.window_width, self.window_height = window_size + self.colors = UIColors() + self.fonts = FontManager() + self.sprite_factory = sdl2.ext.SpriteFactory(renderer=renderer) + + def clear_screen(self, color_name: str = 'black'): + """Clear screen with specified color""" + self.renderer.clear(self.colors.get(color_name)) + + def present(self): + """Present the rendered frame""" + self.renderer.present() + + def draw_background_pattern(self, pattern_type: str = 'gradient'): + """Draw subtle background patterns""" + if pattern_type == 'gradient': + for y in range(0, self.window_height, 20): + alpha = int(20 * (1 - y / self.window_height)) + if alpha > 5: + color = sdl2.ext.Color(alpha, alpha, alpha * 2) + self.renderer.draw_line((0, y, self.window_width, y), color) + + def draw_text(self, text: str, x: int, y: int, color_name: str = 'white', + font_size: str = 'medium', center: bool = False) -> Tuple[int, int]: + """Draw text on screen with improved styling""" + if not text: + return (0, 0) + + color = self.colors.get(color_name) + font = self.fonts.get(font_size) + text_sprite = self.sprite_factory.from_text(text, color=color, fontmanager=font) + + if center: + x = x - text_sprite.size[0] // 2 + + text_sprite.position = (x, y) + self.renderer.copy(text_sprite, dstrect=text_sprite.position) + return text_sprite.size + + def draw_panel(self, x: int, y: int, width: int, height: int, + bg_color: str = 'dark_gray', border_color: str = 'gray', + border_width: int = 2): + """Draw a styled panel/box""" + bg = self.colors.get(bg_color) + border = self.colors.get(border_color) + + # Fill background + self.renderer.fill((x, y, width, height), bg) + + # Draw border + for i in range(border_width): + self.renderer.draw_rect((x + i, y + i, width - 2*i, height - 2*i), border) + + def draw_button(self, text: str, x: int, y: int, width: int, height: int, + selected: bool = False, disabled: bool = False, + font_size: str = 'medium'): + """Draw a styled button""" + # Button colors based on state + if disabled: + bg_color = 'black' + border_color = 'dark_gray' + text_color = 'dark_gray' + elif selected: + bg_color = 'blue' + border_color = 'light_blue' + text_color = 'white' + else: + bg_color = 'dark_gray' + border_color = 'gray' + text_color = 'light_gray' + + # Draw button background and border + self.draw_panel(x, y, width, height, bg_color, border_color) + + # Draw button text centered + text_x = x + width // 2 + text_y = y + height // 2 - 12 # Approximate text height offset + self.draw_text(text, text_x, text_y, text_color, font_size, center=True) + + def draw_header(self, title: str, subtitle: str = '', y_pos: int = 0, + height: int = 80, bg_color: str = 'dark_gray'): + """Draw a header section with title and optional subtitle""" + self.renderer.fill((0, y_pos, self.window_width, height), self.colors.get(bg_color)) + + title_y = y_pos + 15 + self.draw_text(title, self.window_width // 2, title_y, 'light_blue', 'title', center=True) + + if subtitle: + subtitle_y = title_y + 35 + self.draw_text(subtitle, self.window_width // 2, subtitle_y, 'light_green', 'medium', center=True) + + def draw_footer_help(self, help_text: str, y_pos: int = None): + """Draw footer with help text""" + if y_pos is None: + y_pos = self.window_height - 60 + + self.draw_panel(10, y_pos, self.window_width - 20, 40, 'black', 'dark_gray') + self.draw_text(help_text, self.window_width // 2, y_pos + 15, + 'light_gray', 'tiny', center=True) + + def draw_list_item(self, text: str, x: int, y: int, width: int, height: int, + selected: bool = False, secondary_text: str = '', + indicator: str = ''): + """Draw a list item with optional secondary text and indicator""" + # Background + bg_color = 'blue' if selected else 'dark_gray' + border_color = 'light_blue' if selected else 'gray' + text_color = 'white' if selected else 'light_gray' + + self.draw_panel(x, y, width, height, bg_color, border_color) + + # Main text + self.draw_text(text, x + 10, y + 5, text_color, 'medium') + + # Secondary text + if secondary_text: + secondary_color = 'light_gray' if selected else 'gray' + self.draw_text(secondary_text, x + 10, y + 22, secondary_color, 'tiny') + + # Indicator (like ★ for active item) + if indicator: + indicator_x = x + width - 30 + indicator_color = 'light_green' if selected else 'yellow' + self.draw_text(indicator, indicator_x, y + height // 2 - 8, + indicator_color, 'small', center=True) + + def draw_error_dialog(self, message: str, title: str = "ERROR"): + """Draw an error dialog overlay""" + # Semi-transparent overlay + overlay_color = sdl2.ext.Color(0, 0, 0, 128) + self.renderer.fill((0, 0, self.window_width, self.window_height), overlay_color) + + # Error dialog box + dialog_width = 400 + dialog_height = 120 + dialog_x = (self.window_width - dialog_width) // 2 + dialog_y = (self.window_height - dialog_height) // 2 + + # Dialog background with red border + self.draw_panel(dialog_x, dialog_y, dialog_width, dialog_height, + 'black', 'red', border_width=4) + + # Error title + self.draw_text(title, dialog_x + dialog_width // 2, dialog_y + 20, + 'red', 'large', center=True) + + # Error message + self.draw_text(message, dialog_x + dialog_width // 2, dialog_y + 50, + 'white', 'medium', center=True) + + # Dismiss instruction + self.draw_text("Press any key to continue...", + dialog_x + dialog_width // 2, dialog_y + 80, + 'light_gray', 'small', center=True) + + def draw_virtual_keyboard(self, keyboard_layout: List[List[str]], + cursor_x: int, cursor_y: int, + start_x: int = 50, start_y: int = 150): + """Draw a virtual keyboard interface""" + key_width = 45 + key_height = 30 + + for row_idx, row in enumerate(keyboard_layout): + row_y = start_y + row_idx * (key_height + 5) + + # Special handling for bottom row (commands) + if row_idx == len(keyboard_layout) - 1: + key_widths = [80] * len(row) # Wider keys for commands + x_offset = start_x + 90 # Center the bottom row + else: + key_widths = [key_width] * len(row) + x_offset = start_x + + current_x = x_offset + for col_idx, char in enumerate(row): + selected = (row_idx == cursor_y and col_idx == cursor_x) + + # Key styling + if selected: + bg_color = 'blue' + border_color = 'light_blue' + text_color = 'white' + else: + bg_color = 'dark_gray' + border_color = 'gray' + text_color = 'light_gray' + + self.draw_panel(current_x, row_y, key_widths[col_idx], key_height, + bg_color, border_color) + + # Key text + display_char = self._format_keyboard_char(char) + text_x = current_x + key_widths[col_idx] // 2 + text_y = row_y + 8 + self.draw_text(display_char, text_x, text_y, text_color, 'tiny', center=True) + + current_x += key_widths[col_idx] + 5 + + def _format_keyboard_char(self, char: str) -> str: + """Format keyboard character for display""" + char_map = { + ' 0: + fill_width = int(width * (value / max_value)) + if fill_width > 0: + self.renderer.fill((x + 2, y + 2, fill_width - 4, height - 4), + self.colors.get(fill_color)) + + # Value text + text = f"{value}/{max_value}" + text_x = x + width // 2 + text_y = y + 3 + self.draw_text(text, text_x, text_y, 'white', 'tiny', center=True) + + def draw_menu_navigation_arrows(self, selected_index: int, total_items: int, + x: int, y: int): + """Draw navigation arrows for selected menu items""" + if selected_index > 0: + self.draw_text("▲", x, y - 15, 'white', 'small', center=True) + if selected_index < total_items - 1: + self.draw_text("▼", x, y + 15, 'white', 'small', center=True) + + +class GamepadInputHandler: + """Handle gamepad input with debouncing and mapping""" + + def __init__(self, debounce_delay: float = 0.15): + self.gamepad = None + self.button_states = {} + self.last_button_time = {} + self.debounce_delay = debounce_delay + self.init_gamepad() + + def init_gamepad(self): + """Initialize gamepad support""" + sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER) + + num_joysticks = sdl2.SDL_NumJoysticks() + if num_joysticks > 0: + self.gamepad = sdl2.SDL_JoystickOpen(0) + if self.gamepad: + print(f"Gamepad detected: {sdl2.SDL_JoystickName(self.gamepad).decode()}") + else: + print("No gamepad detected - using keyboard fallback") + + def can_process_input(self, input_key: str, current_time: float) -> bool: + """Check if enough time has passed to process input (debouncing)""" + if input_key not in self.last_button_time: + self.last_button_time[input_key] = current_time + return True + + if current_time - self.last_button_time[input_key] > self.debounce_delay: + self.last_button_time[input_key] = current_time + return True + + return False + + def get_gamepad_input(self) -> Dict[str, bool]: + """Get current gamepad input state""" + if not self.gamepad: + return {} + + current_time = time.time() + inputs = {} + + # D-pad navigation + hat_state = sdl2.SDL_JoystickGetHat(self.gamepad, 0) + + if hat_state & sdl2.SDL_HAT_UP and self.can_process_input('up', current_time): + inputs['up'] = True + if hat_state & sdl2.SDL_HAT_DOWN and self.can_process_input('down', current_time): + inputs['down'] = True + if hat_state & sdl2.SDL_HAT_LEFT and self.can_process_input('left', current_time): + inputs['left'] = True + if hat_state & sdl2.SDL_HAT_RIGHT and self.can_process_input('right', current_time): + inputs['right'] = True + + # Buttons + button_count = sdl2.SDL_JoystickNumButtons(self.gamepad) + for i in range(min(button_count, 16)): + if sdl2.SDL_JoystickGetButton(self.gamepad, i): + button_key = f'button_{i}' + if self.can_process_input(button_key, current_time): + inputs[button_key] = True + + return inputs + + def cleanup(self): + """Cleanup gamepad resources""" + if self.gamepad: + sdl2.SDL_JoystickClose(self.gamepad) + self.gamepad = None + + +class ScreenManager: + """Manage different screens/states in the application""" + + def __init__(self): + self.current_screen = "main_menu" + self.screen_stack = [] + self.selected_index = 0 + self.screen_data = {} + + def push_screen(self, screen_name: str, data: Dict[str, Any] = None): + """Push a new screen onto the stack""" + self.screen_stack.append({ + 'screen': self.current_screen, + 'index': self.selected_index, + 'data': self.screen_data.copy() + }) + self.current_screen = screen_name + self.selected_index = 0 + self.screen_data = data or {} + + def pop_screen(self): + """Pop the previous screen from the stack""" + if self.screen_stack: + previous = self.screen_stack.pop() + self.current_screen = previous['screen'] + self.selected_index = previous['index'] + self.screen_data = previous['data'] + + def set_screen(self, screen_name: str, data: Dict[str, Any] = None): + """Set current screen without using stack""" + self.current_screen = screen_name + self.selected_index = 0 + self.screen_data = data or {} diff --git a/profile_manager/user_profile_integration.py b/profile_manager/user_profile_integration.py new file mode 100644 index 0000000..79be36c --- /dev/null +++ b/profile_manager/user_profile_integration.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +""" +User Profile Integration Module +Provides integration between games and the user profile system +""" + +import json +import uuid +import platform +import hashlib +from datetime import datetime +from score_api_client import ScoreAPIClient + + +class UserProfileIntegration: + """Integration layer between the game and profile system""" + + def __init__(self, profiles_file="user_profiles.json", api_url="http://172.27.23.245:8000"): + self.profiles_file = profiles_file + self.current_profile = None + self.device_id = self.generate_device_id() + self.api_client = ScoreAPIClient(api_url) + self.api_enabled = self.api_client.is_server_available() + self.load_active_profile() + + if self.api_enabled: + print(f"✓ Connected to score server at {api_url}") + else: + print(f"✗ Score server not available at {api_url} - running offline") + + def generate_device_id(self): + """Generate a unique device ID based on system information""" + # Get system information + system_info = f"{platform.system()}-{platform.machine()}-{platform.processor()}" + + # Try to get MAC address for more uniqueness + try: + mac = ':'.join(['{:02x}'.format((uuid.getnode() >> ele) & 0xff) + for ele in range(0,8*6,8)][::-1]) + system_info += f"-{mac}" + except: + pass + + # Create hash and take first 8 characters + device_hash = hashlib.md5(system_info.encode()).hexdigest()[:8].upper() + return f"DEV-{device_hash}" + + def load_active_profile(self): + """Load the currently active profile""" + try: + with open(self.profiles_file, 'r') as f: + data = json.load(f) + active_name = data.get('active_profile') + if active_name and active_name in data['profiles']: + self.current_profile = data['profiles'][active_name] + print(f"Loaded profile: {self.current_profile['name']}") + + # Sync with API if available + if self.api_enabled: + self.sync_profile_with_api() + + return True + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Could not load profile: {e}") + + self.current_profile = None + return False + + def get_profile_name(self): + """Get current profile name or default""" + return self.current_profile['name'] if self.current_profile else "Guest Player" + + def get_device_id(self): + """Get the unique device identifier""" + return self.device_id + + def get_setting(self, setting_name, default_value=None): + """Get a setting from the current profile, or return default""" + if self.current_profile and 'settings' in self.current_profile: + return self.current_profile['settings'].get(setting_name, default_value) + return default_value + + def sync_profile_with_api(self): + """Ensure current profile is registered with the API server""" + if not self.current_profile or not self.api_enabled: + return False + + profile_name = self.current_profile['name'] + + # Check if user exists on server + if not self.api_client.user_exists(self.device_id, profile_name): + print(f"Registering {profile_name} with score server...") + result = self.api_client.signup_user(self.device_id, profile_name) + if result.get('success'): + print(f"✓ {profile_name} registered successfully") + return True + else: + print(f"✗ Failed to register {profile_name}: {result.get('message')}") + return False + else: + print(f"✓ {profile_name} already registered on server") + return True + + def register_new_user(self, user_id): + """Register a new user both locally and on the API server""" + if not self.api_enabled: + print("API server not available - user will only be registered locally") + return True + + result = self.api_client.signup_user(self.device_id, user_id) + if result.get('success'): + print(f"✓ {user_id} registered with server successfully") + return True + else: + print(f"✗ Failed to register {user_id} with server: {result.get('message')}") + return False + + def update_game_stats(self, score, completed=True): + """Update the current profile's game statistics""" + if not self.current_profile: + print("No profile loaded - stats not saved") + return False + + # Submit score to API first if available + if self.api_enabled: + profile_name = self.current_profile['name'] + result = self.api_client.submit_score( + self.device_id, + profile_name, + score, + completed + ) + if result.get('success'): + print(f"✓ Score {score} submitted to server successfully") + # Print server stats if available + if 'user_stats' in result: + stats = result['user_stats'] + print(f" Server stats - Games: {stats['total_games']}, Best: {stats['best_score']}") + else: + print(f"✗ Failed to submit score to server: {result.get('message')}") + + try: + # Update local profile + with open(self.profiles_file, 'r') as f: + data = json.load(f) + + profile_name = self.current_profile['name'] + if profile_name in data['profiles']: + profile = data['profiles'][profile_name] + + # Update statistics + if completed: + profile['games_played'] += 1 + print(f"Game completed for {profile_name}! Total games: {profile['games_played']}") + + profile['total_score'] += score + if score > profile['best_score']: + profile['best_score'] = score + print(f"New best score for {profile_name}: {score}!") + + profile['last_played'] = datetime.now().isoformat() + + # Update our local copy + self.current_profile = profile + + # Save back to file + with open(self.profiles_file, 'w') as f: + json.dump(data, f, indent=2) + + print(f"Local profile stats updated: Score +{score}, Total: {profile['total_score']}") + return True + + except Exception as e: + print(f"Error updating profile stats: {e}") + + return False + + def add_achievement(self, achievement_id): + """Add an achievement to the current profile""" + if not self.current_profile: + return False + + try: + with open(self.profiles_file, 'r') as f: + data = json.load(f) + + profile_name = self.current_profile['name'] + if profile_name in data['profiles']: + profile = data['profiles'][profile_name] + + if achievement_id not in profile['achievements']: + profile['achievements'].append(achievement_id) + self.current_profile = profile + + with open(self.profiles_file, 'w') as f: + json.dump(data, f, indent=2) + + print(f"Achievement unlocked for {profile_name}: {achievement_id}") + return True + + except Exception as e: + print(f"Error adding achievement: {e}") + + return False + + def get_profile_info(self): + """Get current profile information for display""" + if self.current_profile: + info = { + 'name': self.current_profile['name'], + 'games_played': self.current_profile['games_played'], + 'best_score': self.current_profile['best_score'], + 'total_score': self.current_profile['total_score'], + 'achievements': len(self.current_profile['achievements']), + 'difficulty': self.current_profile['settings'].get('difficulty', 'normal'), + 'device_id': self.device_id, + 'api_connected': self.api_enabled + } + return info + return None + + def get_device_leaderboard(self, limit=10): + """Get leaderboard for the current device from API server""" + if not self.api_enabled: + print("API server not available - cannot get leaderboard") + return [] + + leaderboard = self.api_client.get_leaderboard(self.device_id, limit) + return leaderboard + + def get_global_leaderboard(self, limit=10): + """Get global leaderboard across all devices from API server""" + if not self.api_enabled: + print("API server not available - cannot get global leaderboard") + return [] + + leaderboard = self.api_client.get_global_leaderboard(limit) + return leaderboard + + def get_all_device_users(self): + """Get all users registered for this device from API server""" + if not self.api_enabled: + print("API server not available - cannot get user list") + return [] + + users = self.api_client.get_device_users(self.device_id) + return users + + def get_user_server_scores(self, user_id=None, limit=10): + """Get recent scores from server for a user (defaults to current profile)""" + if not self.api_enabled: + return [] + + if user_id is None: + if not self.current_profile: + return [] + user_id = self.current_profile['name'] + + scores = self.api_client.get_user_scores(self.device_id, user_id, limit) + return scores + + def reload_profile(self): + """Reload the current profile from disk (useful for external profile changes)""" + return self.load_active_profile() + + +# Convenience functions for quick integration +def get_active_profile(): + """Quick function to get active profile info""" + integration = UserProfileIntegration() + return integration.get_profile_info() + +def update_profile_score(score, completed=True): + """Quick function to update profile score""" + integration = UserProfileIntegration() + return integration.update_game_stats(score, completed) + +def get_profile_setting(setting_name, default_value=None): + """Quick function to get a profile setting""" + integration = UserProfileIntegration() + return integration.get_setting(setting_name, default_value) + +def get_device_leaderboard(limit=10): + """Quick function to get device leaderboard""" + integration = UserProfileIntegration() + return integration.get_device_leaderboard(limit) + +def get_global_leaderboard(limit=10): + """Quick function to get global leaderboard""" + integration = UserProfileIntegration() + return integration.get_global_leaderboard(limit) + + +if __name__ == "__main__": + # Test the integration + print("Testing User Profile Integration with API...") + + integration = UserProfileIntegration() + print(f"Device ID: {integration.get_device_id()}") + print(f"Profile Name: {integration.get_profile_name()}") + print(f"API Connected: {integration.api_enabled}") + + info = integration.get_profile_info() + if info: + print(f"Profile Info: {info}") + else: + print("No profile loaded") + + # Test settings + difficulty = integration.get_setting('difficulty', 'normal') + sound_volume = integration.get_setting('sound_volume', 50) + print(f"Settings - Difficulty: {difficulty}, Sound: {sound_volume}%") + + # Test API features if connected + if integration.api_enabled: + print("\nTesting API features...") + + # Get leaderboard + leaderboard = integration.get_device_leaderboard(5) + if leaderboard: + print("Device Leaderboard:") + for entry in leaderboard: + print(f" {entry['rank']}. {entry['user_id']}: {entry['best_score']} pts ({entry['total_games']} games)") + else: + print("No leaderboard data available") + + # Get all users + users = integration.get_all_device_users() + print(f"\nTotal users on device: {len(users)}") + for user in users: + print(f" {user['user_id']}: Best {user['best_score']}, {user['total_scores']} games") + + # Test score submission + if integration.current_profile: + print(f"\nTesting score submission for {integration.current_profile['name']}...") + result = integration.update_game_stats(1234, True) + print(f"Score update result: {result}") + else: + print("API features not available - server offline") diff --git a/units/__pycache__/rat.cpython-313.pyc b/units/__pycache__/rat.cpython-313.pyc index 01d170aceb03b9c3eb5e5909d71103a09baa051d..e81c4c8f0d0ddc665eaadc8b783d3d35be9fb84f 100644 GIT binary patch delta 47 zcmZ4Gx89HYGcPX}0}vFtuh__)%Ot9%pOK%Ns-K#dmujRRT$Y(rT2PR`xr1qu3IJA3 B4^aRB delta 42 wcmZ4Qx5|(EGcPX}0}!MGmlmGw# diff --git a/units/__pycache__/unit.cpython-313.pyc b/units/__pycache__/unit.cpython-313.pyc index 9ed59d26111d89676b6d9f1ebbe2ea686d08a368..df01ebe126c674c5e2fa997c75829a78aba0fe56 100644 GIT binary patch delta 47 zcmeAY?-%F(%*)Hg00dDDi#Kxfu!*YcXXNLm>Zj)Ar5fo6mu2RZ78K-fR%c6Q1pqFZ B4XFSC delta 42 wcmeAd?-J+!%*)Hg00hkei#Breun8#XXXNLm>Zj)Ar5fqGq?T>gVM}HO0O$t`SpWb4 diff --git a/user_profiles.json b/user_profiles.json index a167f58..62f8e00 100644 --- a/user_profiles.json +++ b/user_profiles.json @@ -3,7 +3,7 @@ "Player1": { "name": "Player1", "created_date": "2024-01-15T10:30:00", - "last_played": "2025-08-21T17:57:06.304649", + "last_played": "2025-08-24T21:49:04.187787", "games_played": 25, "total_score": 15420, "best_score": 980, @@ -23,7 +23,7 @@ "Alice": { "name": "Alice", "created_date": "2024-01-10T09:15:00", - "last_played": "2025-08-21T17:39:28.013833", + "last_played": "2025-08-24T21:48:42.749128", "games_played": 43, "total_score": 33000, "best_score": 1250, @@ -78,10 +78,10 @@ "B0B": { "name": "B0B", "created_date": "2025-08-21T18:03:12.189612", - "last_played": "2025-08-21T18:59:35.393040", + "last_played": "2025-08-22T14:42:49.357304", "games_played": 0, - "total_score": 590, - "best_score": 590, + "total_score": 1410, + "best_score": 820, "settings": { "difficulty": "normal", "sound_volume": 50, @@ -92,5 +92,5 @@ "achievements": [] } }, - "active_profile": "B0B" + "active_profile": "Player1" } \ No newline at end of file