Feat(Levels) - Add level selection menu and database support for level progression; implement level unlocking and navigation between levels

This commit is contained in:
Félix MARQUET
2025-03-28 11:55:24 +01:00
parent 148372dc51
commit 234dbf3f4c
11 changed files with 732 additions and 54 deletions

3
.gitignore vendored
View File

@@ -5,4 +5,5 @@
.idea/*
checkpoint.db
checkpoint.db-journal
checkpoint.db-journal
game.db

86
main.py
View File

@@ -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))

186
map/levels/1.json Normal file
View File

@@ -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"
}
]
}

144
map/levels/2.json Normal file
View File

@@ -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"
}
]
}

View File

@@ -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,)
)

99
src/Database/LevelDB.py Normal file
View File

@@ -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()

View File

@@ -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)

View File

@@ -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

200
src/Menu/LevelSelectMenu.py Normal file
View File

@@ -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

View File

@@ -19,7 +19,7 @@ class Menu:
start_y,
button_width,
button_height,
"play",
"level_select",
)
)

View File

@@ -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}")