diff --git a/src/game_engine.py b/src/game_engine.py index 772d423..302de8f 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -8,6 +8,8 @@ from src.snake.snakeColorGenerator import SnakeColorGenerator from src.environment.pyEnvLibEnvironmentRepositoryImpl import PyEnvLibEnvironmentRepositoryImpl from src.score.game_score import GameScore +from src.score.high_score_entry import HighScoreEntry +from src.score.high_score_repository import HighScoreRepository from src.state.game_state_repository import GameStateRepository logger = logging.getLogger(__name__) @@ -22,6 +24,7 @@ class GameEngine: def __init__(self, config): self.config = config self.state_repository = GameStateRepository() + self.high_score_repository = HighScoreRepository() # Game state self.level = 1 @@ -35,6 +38,9 @@ def __init__(self, config): self.environment_repository = None self.game_score = None self.selected_snake_part = None + + # Track maximum snake length for high score entry + self.max_snake_length = 0 def initialize_game(self): """Initialize or reset the game state""" @@ -103,6 +109,7 @@ def handle_direction_input(self, direction: int) -> bool: def handle_restart(self): """Handle game restart""" logger.info("Restarting the game...") + self._save_high_score_if_eligible() self.game_score.reset() self.check_for_level_progress_and_reinitialize() @@ -126,6 +133,11 @@ def update(self): # Update score self.game_score.calculate() + # Track maximum snake length + current_length = self.snake_part_repository.get_length() + if current_length > self.max_snake_length: + self.max_snake_length = current_length + # Handle tick timing if self.config.limit_tick_speed: self.tick += 1 @@ -143,6 +155,8 @@ def check_for_level_progress_and_reinitialize(self): self.game_score.level_complete() self.level += 1 else: + # Game ended (player died), save high score + self._save_high_score_if_eligible() self.game_score.reset() self.save_game_state() @@ -154,6 +168,20 @@ def check_for_level_progress_and_reinitialize(self): logger.info("Re-initializing the game") self._initialize_level() + def _save_high_score_if_eligible(self): + """Save high score if the current score qualifies""" + if self.game_score and self.game_score.cumulative_points > 0: + entry = HighScoreEntry( + score=self.game_score.cumulative_points, + length=self.max_snake_length, + level=self.level + ) + is_high_score = self.high_score_repository.add_score(entry) + if is_high_score: + logger.info(f"New high score! Score: {entry.score}, Length: {entry.length}, Level: {entry.level}") + # Reset max length for next game + self.max_snake_length = 0 + def get_game_state(self): """Get current game state for rendering""" return { diff --git a/src/graphics/high_scores_menu.py b/src/graphics/high_scores_menu.py index 6db1aac..d53b8a4 100644 --- a/src/graphics/high_scores_menu.py +++ b/src/graphics/high_scores_menu.py @@ -1,6 +1,7 @@ import pygame from src.lib.graphik.src.graphik import Graphik from src.state.menu_state import MenuState +from src.score.high_score_repository import HighScoreRepository class HighScoresMenu: @@ -8,11 +9,35 @@ def __init__(self, config, game_display): self.config = config self.game_display = game_display self.graphik = Graphik(game_display) + self.high_score_repository = HighScoreRepository() + self.scroll_offset = 0 + self.max_visible_scores = 8 # Number of scores visible on screen at once + self._cached_high_scores = None # Cache for high scores + + def refresh_scores(self): + """Reload high scores from repository""" + self._cached_high_scores = self.high_score_repository.load() + + def _get_high_scores(self): + """Get high scores, loading them if not cached""" + if self._cached_high_scores is None: + self.refresh_scores() + return self._cached_high_scores def handle_key_down(self, key): - """Handle keyboard input - return to main menu on escape or enter""" + """Handle keyboard input - return to main menu on escape or enter, scroll with arrow keys""" + high_scores = self._get_high_scores() + max_scroll = max(0, len(high_scores) - self.max_visible_scores) + if key == pygame.K_ESCAPE or key == pygame.K_RETURN: return MenuState.MAIN_MENU + elif key == pygame.K_UP or key == pygame.K_w: + # Scroll up + self.scroll_offset = max(0, self.scroll_offset - 1) + elif key == pygame.K_DOWN or key == pygame.K_s: + # Scroll down + self.scroll_offset = min(max_scroll, self.scroll_offset + 1) + return None def handle_mouse_click(self, pos): @@ -32,28 +57,124 @@ def draw(self): self.game_display.fill(self.config.black) # Draw title + title_y = 50 self.graphik.drawText( "HIGH SCORES", current_width // 2, - current_height // 2 - 100, + title_y, self.config.text_size, self.config.green ) - # Draw placeholder text - self.graphik.drawText( - "High scores coming soon...", - current_width // 2, - current_height // 2, - self.config.text_size // 2, - self.config.white - ) + # Load high scores + high_scores = self._get_high_scores() + + if not high_scores: + # No scores yet + self.graphik.drawText( + "No high scores yet!", + current_width // 2, + current_height // 2, + self.config.text_size // 2, + self.config.white + ) + self.graphik.drawText( + "Play the game to set a high score", + current_width // 2, + current_height // 2 + 50, + self.config.text_size // 3, + self.config.white + ) + else: + # Draw table header + header_y = title_y + 80 + col_rank_x = current_width // 2 - 250 + col_score_x = current_width // 2 - 50 + col_length_x = current_width // 2 + 100 + col_level_x = current_width // 2 + 200 + + header_size = self.config.text_size // 3 + self.graphik.drawText("RANK", col_rank_x, header_y, header_size, self.config.yellow) + self.graphik.drawText("SCORE", col_score_x, header_y, header_size, self.config.yellow) + self.graphik.drawText("LENGTH", col_length_x, header_y, header_size, self.config.yellow) + self.graphik.drawText("LEVEL", col_level_x, header_y, header_size, self.config.yellow) + + # Draw scores + score_start_y = header_y + 60 + score_spacing = 45 + text_size = self.config.text_size // 3 + + # Bronze color for 3rd place + bronze_color = (205, 127, 50) + + # Calculate which scores to display based on scroll offset + start_idx = self.scroll_offset + end_idx = min(start_idx + self.max_visible_scores, len(high_scores)) + + for i in range(start_idx, end_idx): + score_entry = high_scores[i] + y_pos = score_start_y + (i - start_idx) * score_spacing + + # Highlight top 3 scores with different colors + if i == 0: + color = self.config.green # Gold for 1st + elif i == 1: + color = self.config.white # Silver for 2nd + elif i == 2: + color = bronze_color # Bronze for 3rd + else: + color = self.config.white + + # Draw rank (1-indexed) + self.graphik.drawText(f"#{i + 1}", col_rank_x, y_pos, text_size, color) + + # Draw score + self.graphik.drawText(str(score_entry.score), col_score_x, y_pos, text_size, color) + + # Draw length + self.graphik.drawText(str(score_entry.length), col_length_x, y_pos, text_size, color) + + # Draw level + self.graphik.drawText(str(score_entry.level), col_level_x, y_pos, text_size, color) + + # Draw scroll indicators if needed + if self.scroll_offset > 0: + # Can scroll up + self.graphik.drawText( + "▲ More above", + current_width // 2, + score_start_y - 30, + self.config.text_size // 4, + self.config.yellow + ) + + if end_idx < len(high_scores): + # Can scroll down + last_visible_y = score_start_y + (end_idx - start_idx - 1) * score_spacing + self.graphik.drawText( + "▼ More below", + current_width // 2, + last_visible_y + 60, + self.config.text_size // 4, + self.config.yellow + ) # Draw instructions + instructions_y = current_height - 60 self.graphik.drawText( "Press ESC or ENTER to return to main menu", current_width // 2, - current_height // 2 + 100, + instructions_y, self.config.text_size // 3, self.config.yellow - ) \ No newline at end of file + ) + + # Draw scroll instructions if there are enough scores + if len(high_scores) > self.max_visible_scores: + self.graphik.drawText( + "Use UP/DOWN arrows to scroll", + current_width // 2, + instructions_y + 30, + self.config.text_size // 4, + self.config.yellow + ) \ No newline at end of file diff --git a/src/ophidian.py b/src/ophidian.py index 323c257..8fc2107 100644 --- a/src/ophidian.py +++ b/src/ophidian.py @@ -485,6 +485,11 @@ def change_state(self, new_state): elif new_state == MenuState.MAIN_MENU and self.current_state == MenuState.GAME: # Save game state when returning to menu self.save_game_state() + elif new_state == MenuState.HIGH_SCORES: + # Refresh high scores when entering the high scores menu + if hasattr(self, 'high_scores_menu'): + self.high_scores_menu.refresh_scores() + self.high_scores_menu.scroll_offset = 0 # Reset scroll position self.current_state = new_state diff --git a/src/score/high_score_entry.py b/src/score/high_score_entry.py new file mode 100644 index 0000000..232d8aa --- /dev/null +++ b/src/score/high_score_entry.py @@ -0,0 +1,48 @@ +from datetime import datetime + + +class HighScoreEntry: + """Represents a single high score entry""" + + def __init__(self, score, length, level, timestamp=None): + """ + Initialize a high score entry + + Args: + score: The cumulative score achieved + length: The maximum snake length achieved + level: The highest level reached + timestamp: When the score was achieved (defaults to current time) + """ + self.score = score + self.length = length + self.level = level + self.timestamp = timestamp or datetime.now().isoformat() + + def __eq__(self, other): + """Compare two high score entries for equality""" + if not isinstance(other, HighScoreEntry): + return False + return (self.score == other.score and + self.length == other.length and + self.level == other.level and + self.timestamp == other.timestamp) + + def to_dict(self): + """Convert to dictionary for JSON serialization""" + return { + 'score': self.score, + 'length': self.length, + 'level': self.level, + 'timestamp': self.timestamp + } + + @classmethod + def from_dict(cls, data): + """Create from dictionary for JSON deserialization""" + return cls( + score=data.get('score', 0), + length=data.get('length', 0), + level=data.get('level', 1), + timestamp=data.get('timestamp') + ) diff --git a/src/score/high_score_repository.py b/src/score/high_score_repository.py new file mode 100644 index 0000000..f492661 --- /dev/null +++ b/src/score/high_score_repository.py @@ -0,0 +1,119 @@ +import json +import logging +import os +from pathlib import Path +from .high_score_entry import HighScoreEntry + +log_level = os.environ.get('LOG_LEVEL', 'INFO').upper() +logging.basicConfig(level=getattr(logging, log_level)) +logger = logging.getLogger(__name__) + + +class HighScoreRepository: + """Repository for managing high scores with JSON persistence""" + + def __init__(self, file_path='high_scores.json', max_scores=10): + """ + Initialize the high score repository + + Args: + file_path: Path to the JSON file for storing high scores + max_scores: Maximum number of high scores to keep (default: 10) + """ + self.file_path = Path(file_path) + self.max_scores = max_scores + + def save(self, high_scores): + """ + Save high scores to JSON file + + Args: + high_scores: List of HighScoreEntry objects + """ + try: + data = [entry.to_dict() for entry in high_scores] + with open(self.file_path, 'w') as f: + json.dump(data, f, indent=2) + logger.debug(f"Saved {len(high_scores)} high scores to {self.file_path}") + except IOError as e: + logger.error(f"Could not save high scores: {e}") + + def load(self): + """ + Load high scores from JSON file + + Returns: + List of HighScoreEntry objects, sorted by score (highest first) + """ + try: + if self.file_path.exists(): + with open(self.file_path, 'r') as f: + data = json.load(f) + entries = [HighScoreEntry.from_dict(entry) for entry in data] + # Sort by score (highest first) + entries.sort(key=lambda x: x.score, reverse=True) + logger.debug(f"Loaded {len(entries)} high scores from {self.file_path}") + return entries + logger.debug(f"No high scores file found at {self.file_path}") + return [] + except (IOError, json.JSONDecodeError) as e: + logger.error(f"Could not load high scores: {e}") + return [] + + def add_score(self, score_entry): + """ + Add a new high score entry and save + + Args: + score_entry: HighScoreEntry object to add + + Returns: + True if the score was added to the high scores list, False otherwise + """ + high_scores = self.load() + + # Add the new score + high_scores.append(score_entry) + + # Sort by score (highest first) + high_scores.sort(key=lambda x: x.score, reverse=True) + + # Check if the new score made it into the top scores + is_high_score = any(entry == score_entry for entry in high_scores[:self.max_scores]) + + # Keep only the top scores + high_scores = high_scores[:self.max_scores] + + # Save the updated list + self.save(high_scores) + + logger.info(f"Added score {score_entry.score} - Is high score: {is_high_score}") + return is_high_score + + def is_high_score(self, score): + """ + Check if a score qualifies as a high score + + Args: + score: The score to check + + Returns: + True if the score would make it into the high scores list + """ + high_scores = self.load() + + # If we have fewer than max_scores, any score qualifies + if len(high_scores) < self.max_scores: + return True + + # Check if score is higher than the lowest high score + return score > high_scores[-1].score + + def clear(self): + """Clear all high scores""" + try: + if self.file_path.exists(): + self.file_path.unlink() + logger.info("Cleared all high scores") + except IOError as e: + logger.error(f"Could not clear high scores: {e}") diff --git a/tests/graphics/test_high_scores_menu.py b/tests/graphics/test_high_scores_menu.py index 99fa81d..3c302b8 100644 --- a/tests/graphics/test_high_scores_menu.py +++ b/tests/graphics/test_high_scores_menu.py @@ -1,9 +1,10 @@ import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import os import pygame from src.graphics.high_scores_menu import HighScoresMenu from src.state.menu_state import MenuState +from src.score.high_score_entry import HighScoreEntry class TestHighScoresMenu(unittest.TestCase): @@ -38,6 +39,8 @@ def test_high_scores_menu_initialization(self): self.assertIsNotNone(menu.config) self.assertIsNotNone(menu.game_display) self.assertIsNotNone(menu.graphik) + self.assertIsNotNone(menu.high_score_repository) + self.assertEqual(menu.scroll_offset, 0) def test_handle_key_down_escape(self): """Test that escape key returns to main menu""" @@ -63,6 +66,38 @@ def test_handle_key_down_other_keys(self): result = menu.handle_key_down(pygame.K_SPACE) self.assertIsNone(result) + def test_handle_key_down_scroll_up(self): + """Test scrolling up with arrow keys""" + menu = HighScoresMenu(self.mock_config, self.mock_display) + menu.scroll_offset = 3 + + result = menu.handle_key_down(pygame.K_UP) + self.assertIsNone(result) + self.assertEqual(menu.scroll_offset, 2) + + def test_handle_key_down_scroll_down(self): + """Test scrolling down with arrow keys""" + menu = HighScoresMenu(self.mock_config, self.mock_display) + + # Mock high scores to allow scrolling + mock_scores = [ + HighScoreEntry(1000 - i * 100, 50 - i, 5) for i in range(15) + ] + menu._cached_high_scores = mock_scores + + result = menu.handle_key_down(pygame.K_DOWN) + self.assertIsNone(result) + self.assertEqual(menu.scroll_offset, 1) + + def test_handle_key_down_scroll_bounds(self): + """Test that scrolling respects bounds""" + menu = HighScoresMenu(self.mock_config, self.mock_display) + + # Test cannot scroll up past 0 + menu.scroll_offset = 0 + menu.handle_key_down(pygame.K_UP) + self.assertEqual(menu.scroll_offset, 0) + def test_handle_mouse_click(self): """Test mouse click returns to main menu""" menu = HighScoresMenu(self.mock_config, self.mock_display) @@ -70,13 +105,16 @@ def test_handle_mouse_click(self): result = menu.handle_mouse_click((100, 100)) self.assertEqual(result, MenuState.MAIN_MENU) - def test_draw_method(self): - """Test that draw method makes expected calls""" + def test_draw_method_no_scores(self): + """Test drawing when there are no high scores""" menu = HighScoresMenu(self.mock_config, self.mock_display) # Mock the graphik methods menu.graphik.drawText = MagicMock() + # Set cached high scores to empty list + menu._cached_high_scores = [] + menu.draw() # Should call fill on display @@ -89,6 +127,37 @@ def test_draw_method(self): title_calls = [call for call in menu.graphik.drawText.call_args_list if len(call[0]) > 0 and call[0][0] == "HIGH SCORES"] self.assertTrue(len(title_calls) > 0) + + # Check that "No high scores yet!" message is drawn + no_scores_calls = [call for call in menu.graphik.drawText.call_args_list + if len(call[0]) > 0 and "No high scores yet" in call[0][0]] + self.assertTrue(len(no_scores_calls) > 0) + + def test_draw_method_with_scores(self): + """Test drawing when there are high scores""" + menu = HighScoresMenu(self.mock_config, self.mock_display) + + # Mock the graphik methods + menu.graphik.drawText = MagicMock() + + # Set cached high scores + test_scores = [ + HighScoreEntry(1000, 50, 5), + HighScoreEntry(800, 40, 4), + HighScoreEntry(600, 30, 3) + ] + menu._cached_high_scores = test_scores + + menu.draw() + + # Should draw text multiple times + self.assertTrue(menu.graphik.drawText.called) + + # Verify scores are being drawn (check for the score values) + all_args = [str(call[0][0]) for call in menu.graphik.drawText.call_args_list] + self.assertIn("1000", all_args) + self.assertIn("800", all_args) + self.assertIn("600", all_args) if __name__ == '__main__': diff --git a/tests/score/test_high_score_repository.py b/tests/score/test_high_score_repository.py new file mode 100644 index 0000000..1fb3936 --- /dev/null +++ b/tests/score/test_high_score_repository.py @@ -0,0 +1,211 @@ +import unittest +import tempfile +import os +from pathlib import Path +from datetime import datetime + +from src.score.high_score_entry import HighScoreEntry +from src.score.high_score_repository import HighScoreRepository + + +class TestHighScoreEntry(unittest.TestCase): + """Test the HighScoreEntry class""" + + def test_initialization(self): + """Test creating a high score entry""" + entry = HighScoreEntry(score=1000, length=50, level=5) + + self.assertEqual(entry.score, 1000) + self.assertEqual(entry.length, 50) + self.assertEqual(entry.level, 5) + self.assertIsNotNone(entry.timestamp) + + def test_initialization_with_timestamp(self): + """Test creating a high score entry with custom timestamp""" + timestamp = "2024-01-01T12:00:00" + entry = HighScoreEntry(score=1000, length=50, level=5, timestamp=timestamp) + + self.assertEqual(entry.timestamp, timestamp) + + def test_to_dict(self): + """Test converting to dictionary""" + entry = HighScoreEntry(score=1000, length=50, level=5, timestamp="2024-01-01T12:00:00") + data = entry.to_dict() + + self.assertEqual(data['score'], 1000) + self.assertEqual(data['length'], 50) + self.assertEqual(data['level'], 5) + self.assertEqual(data['timestamp'], "2024-01-01T12:00:00") + + def test_from_dict(self): + """Test creating from dictionary""" + data = { + 'score': 1000, + 'length': 50, + 'level': 5, + 'timestamp': "2024-01-01T12:00:00" + } + entry = HighScoreEntry.from_dict(data) + + self.assertEqual(entry.score, 1000) + self.assertEqual(entry.length, 50) + self.assertEqual(entry.level, 5) + self.assertEqual(entry.timestamp, "2024-01-01T12:00:00") + + +class TestHighScoreRepository(unittest.TestCase): + """Test the HighScoreRepository class""" + + def setUp(self): + """Set up test fixtures""" + # Create a temporary file for testing + self.temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') + self.temp_file.close() + self.repository = HighScoreRepository(file_path=self.temp_file.name, max_scores=5) + + def tearDown(self): + """Clean up after tests""" + # Remove the temporary file + if os.path.exists(self.temp_file.name): + os.unlink(self.temp_file.name) + + def test_save_and_load_empty_list(self): + """Test saving and loading an empty list""" + self.repository.save([]) + loaded = self.repository.load() + self.assertEqual(loaded, []) + + def test_save_and_load_single_entry(self): + """Test saving and loading a single entry""" + entry = HighScoreEntry(score=1000, length=50, level=5) + self.repository.save([entry]) + + loaded = self.repository.load() + self.assertEqual(len(loaded), 1) + self.assertEqual(loaded[0].score, 1000) + self.assertEqual(loaded[0].length, 50) + self.assertEqual(loaded[0].level, 5) + + def test_save_and_load_multiple_entries(self): + """Test saving and loading multiple entries""" + entries = [ + HighScoreEntry(score=1000, length=50, level=5), + HighScoreEntry(score=800, length=40, level=4), + HighScoreEntry(score=600, length=30, level=3) + ] + self.repository.save(entries) + + loaded = self.repository.load() + self.assertEqual(len(loaded), 3) + # Verify they're sorted by score (highest first) + self.assertEqual(loaded[0].score, 1000) + self.assertEqual(loaded[1].score, 800) + self.assertEqual(loaded[2].score, 600) + + def test_load_sorts_by_score(self): + """Test that loading sorts entries by score""" + # Save entries in unsorted order + entries = [ + HighScoreEntry(score=600, length=30, level=3), + HighScoreEntry(score=1000, length=50, level=5), + HighScoreEntry(score=800, length=40, level=4) + ] + self.repository.save(entries) + + loaded = self.repository.load() + # Should be sorted by score (highest first) + self.assertEqual(loaded[0].score, 1000) + self.assertEqual(loaded[1].score, 800) + self.assertEqual(loaded[2].score, 600) + + def test_load_nonexistent_file(self): + """Test loading when file doesn't exist""" + # Use a non-existent file path in a cross-platform way + nonexistent_path = os.path.join(tempfile.gettempdir(), 'nonexistent_high_scores.json') + repo = HighScoreRepository(file_path=nonexistent_path) + loaded = repo.load() + self.assertEqual(loaded, []) + + def test_add_score_to_empty_list(self): + """Test adding a score to an empty list""" + entry = HighScoreEntry(score=1000, length=50, level=5) + is_high_score = self.repository.add_score(entry) + + self.assertTrue(is_high_score) + loaded = self.repository.load() + self.assertEqual(len(loaded), 1) + self.assertEqual(loaded[0].score, 1000) + + def test_add_score_maintains_max_scores(self): + """Test that only max_scores entries are kept""" + # Add 6 scores (max is 5) + for i in range(6): + entry = HighScoreEntry(score=1000 - i * 100, length=50 - i * 5, level=5) + self.repository.add_score(entry) + + loaded = self.repository.load() + self.assertEqual(len(loaded), 5) + # Lowest score should not be in the list + self.assertNotEqual(loaded[-1].score, 500) + + def test_add_score_returns_correct_status(self): + """Test that add_score returns correct high score status""" + # Fill up with 5 scores (max is 5) + for i in range(5): + entry = HighScoreEntry(score=1000 - i * 100, length=50, level=5) + self.repository.add_score(entry) + + # Add a score that makes it into the list + high_entry = HighScoreEntry(score=900, length=50, level=5) + is_high_score = self.repository.add_score(high_entry) + self.assertTrue(is_high_score) + + # Add a score that doesn't make it into the list + low_entry = HighScoreEntry(score=100, length=10, level=1) + is_high_score = self.repository.add_score(low_entry) + self.assertFalse(is_high_score) + + def test_is_high_score_with_empty_list(self): + """Test is_high_score with empty list""" + self.assertTrue(self.repository.is_high_score(100)) + + def test_is_high_score_with_partial_list(self): + """Test is_high_score when list is not full""" + # Add 3 scores (max is 5) + for i in range(3): + entry = HighScoreEntry(score=1000 - i * 100, length=50, level=5) + self.repository.add_score(entry) + + # Any score should qualify + self.assertTrue(self.repository.is_high_score(100)) + + def test_is_high_score_with_full_list(self): + """Test is_high_score when list is full""" + # Fill up with 5 scores (max is 5) + for i in range(5): + entry = HighScoreEntry(score=1000 - i * 100, length=50, level=5) + self.repository.add_score(entry) + + # Score higher than lowest should qualify + self.assertTrue(self.repository.is_high_score(700)) + + # Score lower than lowest should not qualify + self.assertFalse(self.repository.is_high_score(500)) + + def test_clear(self): + """Test clearing all high scores""" + # Add some scores + for i in range(3): + entry = HighScoreEntry(score=1000 - i * 100, length=50, level=5) + self.repository.add_score(entry) + + # Clear + self.repository.clear() + + # Should be empty now + loaded = self.repository.load() + self.assertEqual(loaded, []) + + +if __name__ == '__main__': + unittest.main()