diff --git a/.gitignore b/.gitignore index 6c30992..f2a75b6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .idea/* checkpoint.db -checkpoint.db-journal \ No newline at end of file +checkpoint.db-journal +game.db \ No newline at end of file diff --git a/main.py b/main.py index 311f729..ae258a4 100644 --- a/main.py +++ b/main.py @@ -1,11 +1,14 @@ +import re + import pygame import sys from pygame.locals import * +from src.Database.LevelDB import LevelDB from src.Entity.Enemy import Enemy +from src.Menu.LevelSelectMenu import LevelSelectMenu from src.game import ( initialize_game, - reset_game, reset_game_with_checkpoint, clear_checkpoint_database, ) @@ -40,15 +43,13 @@ def main(): # Initialize game state and objects current_state = MENU - menu = Menu(game_resources) + main_menu = Menu(game_resources) + level_select_menu = None + level_file = "map/levels/1.json" + current_menu = "main" leaderboard = Leaderboard(WIDTH, HEIGHT, font) clear_checkpoint_database() - - # Initialize game components - P1, PT1, platforms, all_sprites, background, checkpoints, exits = initialize_game( - game_resources, "map_test.json" - ) projectiles = pygame.sprite.Group() # Main game loop @@ -89,12 +90,12 @@ def main(): elif event.type == USEREVENT: if event.action == "player_death": db = CheckpointDB() - checkpoint_pos = db.get_checkpoint("map_test.json") + checkpoint_pos = db.get_checkpoint(level_file) if checkpoint_pos: # Respawn player at checkpoint P1, platforms, all_sprites, background, checkpoints = ( - reset_game_with_checkpoint("map_test.json", game_resources) + reset_game_with_checkpoint(level_file, game_resources) ) projectiles.empty() else: @@ -106,19 +107,38 @@ def main(): # Handle menu events if current_state == MENU: - action = menu.handle_event(event) - if action == "play": - P1, platforms, all_sprites, background, checkpoints = reset_game( - game_resources - ) - current_state = PLAYING - elif action == "infinite": - current_state = INFINITE - elif action == "leaderboard": - current_state = LEADERBOARD - elif action == "quit": - pygame.quit() - sys.exit() + if current_menu == "main": + action = main_menu.handle_event(event) + if action == "level_select": + level_select_menu = LevelSelectMenu(game_resources) + current_menu = "level_select" + elif action == "infinite": + current_state = INFINITE + elif action == "leaderboard": + current_state = LEADERBOARD + elif action == "quit": + pygame.quit() + sys.exit() + elif current_menu == "level_select": + action = level_select_menu.handle_event(event) + if action == "back_to_main": + current_menu = "main" + elif ( + isinstance(action, dict) + and action.get("action") == "select_level" + ): + level_file = action.get("level_file") + ( + P1, + PT1, + platforms, + all_sprites, + background, + checkpoints, + exits, + ) = initialize_game(game_resources, level_file) + projectiles.empty() + current_state = PLAYING # Handle leaderboard events elif current_state == LEADERBOARD: @@ -131,7 +151,10 @@ def main(): # Draw appropriate screen based on state if current_state == MENU: - menu.draw(displaysurface) + if current_menu == "main": + main_menu.draw(displaysurface) + elif current_menu == "level_select": + level_select_menu.draw(displaysurface) elif current_state == PLAYING: # Regular game code @@ -195,6 +218,23 @@ def main(): for checkpoint in checkpoints_hit: checkpoint.activate() + exits_hit = pygame.sprite.spritecollide(P1, exits, False) if exits else [] + for exit in exits_hit: + current_level_match = re.search(r"(\d+)\.json$", level_file) + if current_level_match: + current_level = int(current_level_match.group(1)) + next_level = current_level + 1 + + # Unlock next level + db = LevelDB() + db.unlock_level(next_level) + db.close() + + # Return to level select menu + current_state = MENU + current_menu = "level_select" + level_select_menu = LevelSelectMenu(game_resources) + # Display FPS and coordinates (fixed position UI elements) fps = int(FramePerSec.get_fps()) fps_text = font.render(f"FPS: {fps}", True, (255, 255, 255)) diff --git a/map/levels/1.json b/map/levels/1.json new file mode 100644 index 0000000..dc2fa9f --- /dev/null +++ b/map/levels/1.json @@ -0,0 +1,186 @@ +{ + "name": "Level 1", + "width": 2400, + "height": 800, + "background": "assets/map/background/forest_bg.jpg", + "gravity": 1.0, + + "ground": [ + { + "id": "main_ground", + "x": -1000, + "y": 780, + "width": 1800, + "height": 200, + "texture": "assets/map/platform/grass_texture.jpg" + }, + { + "id": "pit", + "x": 800, + "y": 780, + "width": 200, + "height": 20, + "is_hole": true + }, + { + "id": "main_ground_2", + "x": 1000, + "y": 900, + "width": 1800, + "height": 200, + "texture": "assets/map/platform/grass_texture.jpg" + } + ], + + "platforms": [ + { + "id": "platform1", + "x": 300, + "y": 600, + "width": 200, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": false + }, + { + "id": "platform2", + "x": 700, + "y": 500, + "width": 150, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": true, + "movement": { + "type": "linear", + "points": [ + {"x": 700, "y": 300}, + {"x": 700, "y": 500} + ], + "speed": 2.0, + "wait_time": 1.0 + } + }, + { + "id": "platform21", + "x": 900, + "y": 500, + "width": 150, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": true, + "movement": { + "type": "linear", + "points": [ + {"x": 700, "y": 500}, + {"x": 800, "y": 500} + ], + "speed": 2.0, + "wait_time": 1.0 + } + }, + { + "id": "platform3", + "x": 1200, + "y": 400, + "width": 100, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": true, + "movement": { + "type": "circular", + "center": {"x": 1200, "y": 400}, + "radius": 100, + "speed": 0.02, + "clockwise": true + } + } + ], + + "enemies": [ + { + "id": "enemy1", + "type": "walker", + "x": 500, + "y": 620, + "health": 1, + "damage": 1, + "behavior": "patrol", + "patrol_points": [ + {"x": 400, "y": 620}, + {"x": 600, "y": 620} + ], + "speed": 1.5, + "sprite_sheet": "assets/map/enemy/walker_enemy.png" + }, + { + "id": "enemy2", + "type": "flyer", + "x": 1000, + "y": 400, + "health": 1, + "damage": 1, + "behavior": "chase", + "detection_radius": 200, + "speed": 2.0, + "sprite_sheet": "assets/map/enemy/flying_enemy.png" + }, + { + "id": "enemy3", + "type": "turret", + "x": 1500, + "y": 700, + "health": 1, + "damage": 1, + "behavior": "stationary", + "attack_interval": 2.0, + "attack_range": 300, + "sprite_sheet": "assets/map/enemy/turret_enemy.png" + } + ], + + "collectibles": [ + { + "id": "coin1", + "type": "coin", + "x": 350, + "y": 550, + "value": 10, + "sprite": "assets/map/collectibles/coin.png" + }, + { + "id": "power_up1", + "type": "speed_boost", + "x": 900, + "y": 450, + "duration": 5.0, + "sprite": "assets/map/collectibles/speed_boost.png" + } + ], + + "checkpoints": [ + { + "id": "checkpoint1", + "x": 1200, + "y": 760, + "width": 50, + "height": 50, + "sprite": "assets/map/checkpoints/checkpoint.png" + } + ], + + "spawn_point": { + "x": 50, + "y": 700 + }, + + "exits": [ + { + "x": 2300, + "y": 700, + "width": 50, + "height": 80, + "next_level": "Level 2", + "sprite": "assets/map/exit/door.png" + } + ] +} \ No newline at end of file diff --git a/map/levels/2.json b/map/levels/2.json new file mode 100644 index 0000000..25406a0 --- /dev/null +++ b/map/levels/2.json @@ -0,0 +1,144 @@ +{ + "name": "Level 2", + "width": 2400, + "height": 800, + "background": "assets/map/background/forest_bg.jpg", + "gravity": 1.0, + + "ground": [ + { + "id": "main_ground", + "x": -1000, + "y": 780, + "width": 1800, + "height": 200, + "texture": "assets/map/platform/grass_texture.jpg" + }, + { + "id": "pit", + "x": 800, + "y": 780, + "width": 200, + "height": 20, + "is_hole": true + }, + { + "id": "main_ground_2", + "x": 1000, + "y": 900, + "width": 1800, + "height": 200, + "texture": "assets/map/platform/grass_texture.jpg" + } + ], + + "platforms": [ + { + "id": "platform1", + "x": 300, + "y": 600, + "width": 200, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": false + }, + { + "id": "platform2", + "x": 700, + "y": 500, + "width": 150, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": true, + "movement": { + "type": "linear", + "points": [ + {"x": 700, "y": 300}, + {"x": 700, "y": 500} + ], + "speed": 2.0, + "wait_time": 1.0 + } + }, + { + "id": "platform21", + "x": 900, + "y": 500, + "width": 150, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": true, + "movement": { + "type": "linear", + "points": [ + {"x": 700, "y": 500}, + {"x": 800, "y": 500} + ], + "speed": 2.0, + "wait_time": 1.0 + } + }, + { + "id": "platform3", + "x": 1200, + "y": 400, + "width": 100, + "height": 20, + "texture": "assets/map/platform/grass_texture.jpg", + "is_moving": true, + "movement": { + "type": "circular", + "center": {"x": 1200, "y": 400}, + "radius": 100, + "speed": 0.02, + "clockwise": true + } + } + ], + + "collectibles": [ + { + "id": "coin1", + "type": "coin", + "x": 350, + "y": 550, + "value": 10, + "sprite": "assets/map/collectibles/coin.png" + }, + { + "id": "power_up1", + "type": "speed_boost", + "x": 900, + "y": 450, + "duration": 5.0, + "sprite": "assets/map/collectibles/speed_boost.png" + } + ], + + "checkpoints": [ + { + "id": "checkpoint1", + "x": 1200, + "y": 760, + "width": 50, + "height": 50, + "sprite": "assets/map/checkpoints/checkpoint.png" + } + ], + + "spawn_point": { + "x": 50, + "y": 700 + }, + + "exits": [ + { + "x": 2300, + "y": 700, + "width": 50, + "height": 80, + "next_level": "Level 3", + "sprite": "assets/map/exit/door.png" + } + ] +} \ No newline at end of file diff --git a/src/Database/CheckpointDB.py b/src/Database/CheckpointDB.py index 93dc6be..db855c1 100644 --- a/src/Database/CheckpointDB.py +++ b/src/Database/CheckpointDB.py @@ -3,7 +3,7 @@ import os class CheckpointDB: - def __init__(self, db_file="checkpoint.db"): + def __init__(self, db_file="game.db"): """ Initialize database connection for checkpoint management @@ -42,11 +42,15 @@ class CheckpointDB: pos_x: X coordinate pos_y: Y coordinate """ - self.cursor.execute( - "INSERT OR REPLACE INTO checkpoints (map_name, pos_x, pos_y, timestamp) VALUES (?, ?, ?, strftime('%s'))", - (map_name, pos_x, pos_y), - ) - self.conn.commit() + try: + self.cursor.execute( + "INSERT OR REPLACE INTO checkpoints (map_name, pos_x, pos_y, timestamp) VALUES (?, ?, ?, strftime('%s'))", + (map_name, pos_x, pos_y), + ) + self.conn.commit() + print("Checkpoint saved") + except Exception as e: + print(f"Error saving checkpoint: {e}") def get_checkpoint(self, map_name): """ @@ -58,6 +62,7 @@ class CheckpointDB: Returns: Tuple (x, y) if checkpoint exists, None otherwise """ + print(map_name) self.cursor.execute( "SELECT pos_x, pos_y FROM checkpoints WHERE map_name = ?", (map_name,) ) diff --git a/src/Database/LevelDB.py b/src/Database/LevelDB.py new file mode 100644 index 0000000..e19bc22 --- /dev/null +++ b/src/Database/LevelDB.py @@ -0,0 +1,99 @@ +import sqlite3 +import os + + +class LevelDB: + def __init__(self, db_file="game.db"): + """ + Initialize database connection for level progression management + + Args: + db_file: SQLite database file path + """ + # Create database directory if it doesn't exist + os.makedirs( + os.path.dirname(db_file) if os.path.dirname(db_file) else ".", exist_ok=True + ) + + self.connection = sqlite3.connect(db_file) + self.cursor = self.connection.cursor() + self.create_unlocked_levels_table() + + def create_unlocked_levels_table(self): + """Create the table to store unlocked levels if it doesn't exist""" + self.cursor.execute( + """ + CREATE TABLE IF NOT EXISTS unlocked_levels ( + level_number INTEGER PRIMARY KEY, + unlocked INTEGER DEFAULT 0, + timestamp TEXT DEFAULT (strftime('%s')) + ) + """ + ) + self.connection.commit() + + def is_level_unlocked(self, level_number): + """ + Check if a specific level is unlocked + + Args: + level_number: Level number to check + + Returns: + bool: True if level is unlocked, False otherwise + """ + # Level 1 is always unlocked + if level_number == 1: + return True + + self.cursor.execute( + "SELECT unlocked FROM unlocked_levels WHERE level_number = ?", + (level_number,), + ) + result = self.cursor.fetchone() + return bool(result and result[0]) + + def unlock_level(self, level_number): + """ + Unlock a specific level + + Args: + level_number: Level number to unlock + """ + self.cursor.execute( + "INSERT OR REPLACE INTO unlocked_levels (level_number, unlocked) VALUES (?, 1)", + (level_number,), + ) + self.connection.commit() + print(f"Level {level_number} unlocked") + + def get_all_unlocked_levels(self): + """ + Get a list of all unlocked level numbers + + Returns: + list: List of unlocked level numbers + """ + self.cursor.execute( + "SELECT level_number FROM unlocked_levels WHERE unlocked = 1" + ) + return [1] + [ + row[0] for row in self.cursor.fetchall() + ] # Level 1 + all unlocked levels + + def reset_progress(self): + """ + Reset all progress, keeping only level 1 unlocked + """ + try: + self.cursor.execute("DELETE FROM unlocked_levels") + self.connection.commit() + self.unlock_level(1) # Always unlock level 1 + print("Level progress reset successfully") + except Exception as e: + print(f"Error resetting level progress: {e}") + + def close(self): + """Close database connection""" + if self.connection: + self.connection.close() diff --git a/src/Entity/Checkpoint.py b/src/Entity/Checkpoint.py index c60ccfa..3c92752 100644 --- a/src/Entity/Checkpoint.py +++ b/src/Entity/Checkpoint.py @@ -39,7 +39,6 @@ class Checkpoint(Entity): if not hasattr(self, "original_surf"): self.original_surf = self.surf.copy() self.surf.fill(self.activated_color) - # Save checkpoint to database self.db.save_checkpoint(self.map_name, self.pos.x, self.pos.y) diff --git a/src/Menu/Button.py b/src/Menu/Button.py index e735df5..4e2d48f 100644 --- a/src/Menu/Button.py +++ b/src/Menu/Button.py @@ -3,7 +3,7 @@ import pygame class Button: - def __init__(self, text, x, y, width, height, action=None): + def __init__(self, text, x, y, width, height, action=None, locked=False): self.text = text self.x = x self.y = y @@ -11,25 +11,38 @@ class Button: self.height = height self.action = action self.hover = False + self.locked = locked + self.rect = pygame.Rect(x, y, width, height) def draw(self, surface, font): # Button colors - color = (100, 149, 237) if self.hover else (65, 105, 225) - border_color = (255, 255, 255) + if self.locked: + bg_color = (100, 100, 100) + text_color = (200, 200, 200) + elif self.hover: + bg_color = (100, 100, 255) + text_color = (255, 255, 255) + else: + bg_color = (50, 50, 200) + text_color = (255, 255, 255) # Draw button with border - pygame.draw.rect(surface, color, (self.x, self.y, self.width, self.height)) - pygame.draw.rect( - surface, border_color, (self.x, self.y, self.width, self.height), 2 - ) + pygame.draw.rect(surface, bg_color, self.rect, border_radius=10) + pygame.draw.rect(surface, (0, 0, 0), self.rect, 2, border_radius=10) # Draw text - text_surf = font.render(self.text, True, (255, 255, 255)) - text_rect = text_surf.get_rect( - center=(self.x + self.width / 2, self.y + self.height / 2) - ) + text_surf = font.render(self.text, True, text_color) + text_rect = text_surf.get_rect(center=self.rect.center) surface.blit(text_surf, text_rect) + # Add lock icon if button is locked + if self.locked: + lock_text = font.render("🔒", True, (255, 255, 255)) + lock_rect = lock_text.get_rect( + center=(self.rect.right - 20, self.rect.y + 20) + ) + surface.blit(lock_text, lock_rect) + def is_hover(self, pos): return ( self.x <= pos[0] <= self.x + self.width diff --git a/src/Menu/LevelSelectMenu.py b/src/Menu/LevelSelectMenu.py new file mode 100644 index 0000000..1c71236 --- /dev/null +++ b/src/Menu/LevelSelectMenu.py @@ -0,0 +1,200 @@ +import pygame +import os +import re + +from src.Database.LevelDB import LevelDB +from src.Menu.Button import Button +from src.game import clear_checkpoint_database + + +class LevelSelectMenu: + """ + A menu for selecting game levels loaded from JSON files. + Presents all available levels from the map/levels/ directory as buttons. + """ + + def __init__(self, game_resources): + """ + Initialize the level selection menu. + + Args: + game_resources: GameResources object containing game settings and resources + """ + self.game_resources = game_resources + self.buttons = [] + self.levels = [] + + # Button dimensions + self.button_width = 250 + self.button_height = 60 + self.button_spacing = 20 + + # Initialize database and get unlocked levels + self.db = LevelDB() + self.db.create_unlocked_levels_table() + self.unlocked_levels = self.db.get_all_unlocked_levels() + + # Scan for level files + self._scan_levels() + + # Generate level buttons + self._create_buttons() + + # Add back button and reset progress button + self._add_navigation_buttons() + + def _scan_levels(self): + """ + Scan the levels directory for JSON level files and sort them numerically. + """ + try: + # Get all JSON files in the levels directory + level_dir = "map/levels/" + if not os.path.exists(level_dir): + os.makedirs(level_dir) # Create directory if it doesn't exist + + files = [f for f in os.listdir(level_dir) if f.endswith(".json")] + + # Extract level numbers using regex and sort numerically + level_pattern = re.compile(r"(\d+)\.json$") + self.levels = [] + + for file in files: + match = level_pattern.search(file) + if match: + level_number = int(match.group(1)) + self.levels.append((level_number, f"{level_dir}{file}")) + + # Sort levels by number + self.levels.sort(key=lambda x: x[0]) + + except Exception as e: + print(f"Error scanning levels: {e}") + self.levels = [] + + def _create_buttons(self): + """ + Create buttons for each available level. + """ + # Calculate how many buttons can fit per row + buttons_per_row = 3 + button_width_with_spacing = self.button_width + self.button_spacing + + # Start position for the grid of buttons + start_x = ( + self.game_resources.WIDTH + - (button_width_with_spacing * min(buttons_per_row, len(self.levels) or 1)) + ) // 2 + start_y = self.game_resources.HEIGHT // 3 + + # Create buttons for each level + for i, (level_num, level_file) in enumerate(self.levels): + # Calculate position in grid + row = i // buttons_per_row + col = i % buttons_per_row + + x = start_x + (col * button_width_with_spacing) + y = start_y + (row * (self.button_height + self.button_spacing)) + + # Check if level is unlocked + is_unlocked = self.db.is_level_unlocked(level_num) + button_text = f"Level {level_num}" + button_color = None + + # If locked, disable button functionality + action = ( + {"action": "select_level", "level_file": level_file} + if is_unlocked + else None + ) + + # Create button + self.buttons.append( + Button( + button_text, + x, + y, + self.button_width, + self.button_height, + action, + locked=not is_unlocked, + ) + ) + + def _add_navigation_buttons(self): + """ + Add navigation buttons (back and reset progress). + """ + # Back button + self.buttons.append( + Button( + "Back", + self.game_resources.WIDTH // 4 - self.button_width // 2, + self.game_resources.HEIGHT - 100, + self.button_width, + self.button_height, + "back_to_main", + ) + ) + + # Reset progress button + self.buttons.append( + Button( + "Reset Progress", + 3 * self.game_resources.WIDTH // 4 - self.button_width // 2, + self.game_resources.HEIGHT - 100, + self.button_width, + self.button_height, + "reset_progress", + ) + ) + + def draw(self, surface): + """ + Draw the level selection menu. + + Args: + surface: Pygame surface to draw on + """ + # Draw title + title = pygame.font.SysFont("Arial", 48).render( + "Select Level", True, (0, 191, 255) + ) + title_rect = title.get_rect( + center=(self.game_resources.WIDTH // 2, self.game_resources.HEIGHT // 6) + ) + surface.blit(title, title_rect) + + # Draw buttons + for button in self.buttons: + button.draw(surface, self.game_resources.font) + + # Display message if no levels found + if not self.levels: + no_levels = pygame.font.SysFont("Arial", 32).render( + "No levels found", True, (255, 0, 0) + ) + no_levels_rect = no_levels.get_rect( + center=(self.game_resources.WIDTH // 2, self.game_resources.HEIGHT // 2) + ) + surface.blit(no_levels, no_levels_rect) + + def handle_event(self, event): + """ + Handle user input events. + + Args: + event: Pygame event to process + + Returns: + dict/str/None: Action to perform based on button clicked, or None + """ + for button in self.buttons: + action = button.handle_event(event) + if action: + if action == "reset_progress": + # Clear checkpoint database + clear_checkpoint_database() + return None # Stay in the level select menu + return action + return None diff --git a/src/Menu/Menu.py b/src/Menu/Menu.py index 1fac3eb..2f47cf3 100644 --- a/src/Menu/Menu.py +++ b/src/Menu/Menu.py @@ -19,7 +19,7 @@ class Menu: start_y, button_width, button_height, - "play", + "level_select", ) ) diff --git a/src/game.py b/src/game.py index 0dbd68d..491b9a2 100644 --- a/src/game.py +++ b/src/game.py @@ -7,7 +7,7 @@ from src.Map.parser import MapParser from src.Database.CheckpointDB import CheckpointDB -def initialize_game(game_resources, map_file="map_test.json"): +def initialize_game(game_resources, map_file="map/levels/1.json"): """ Initialize game with map from JSON file @@ -60,16 +60,6 @@ def initialize_game(game_resources, map_file="map_test.json"): ) -def reset_game(game_resources): - """Reset the game to initial state""" - # Reload game objects - player, _, platforms, all_sprites, background, checkpoints, exits = initialize_game( - game_resources, "map_test.json" - ) - - return player, platforms, all_sprites, background, checkpoints - - def reset_game_with_checkpoint(map_name, game_resources): """ Reset the game and respawn player at checkpoint if available @@ -103,6 +93,7 @@ def clear_checkpoint_database(): try: db = CheckpointDB() db.clear_all() + db.close() print("Checkpoint database cleared successfully") except Exception as e: print(f"Error clearing checkpoint database: {e}")