diff --git a/main.py b/main.py index 7a7524c..cd9ac52 100644 --- a/main.py +++ b/main.py @@ -23,17 +23,12 @@ # import Pygame specific objects, functions and functionality -from src.GUI.gameloop import GUI - - -class Launcher: - def __init__(self) -> None: - pass +from src.game_manager import GameStateManager if __name__ == "__main__": # version choice is disabled for debugging reasons - game = GUI() + game = GameStateManager() game.run() # print( diff --git a/setup.py b/setup.py index 284bb9f..49be575 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,7 @@ -"""Could be usefull if we ever make the decision to create a pip package for this project, as for now it is commented out.""" +""" +Could be usefull if we ever make the decision to create a pip package for this project, +as for now it is commented out. +""" # from setuptools import setup diff --git a/src/GUI/__init__.py b/src/GUI/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/GUI/gameloop.py b/src/GUI/gameloop.py deleted file mode 100644 index 5d2efd4..0000000 --- a/src/GUI/gameloop.py +++ /dev/null @@ -1,151 +0,0 @@ -from os.path import join -import sys -import json - -# import dataclasses and typchecking -from dataclasses import dataclass, field - -# import pygame related -import pygame -from pytmx.util_pygame import load_pygame # type: ignore - -# import Pygame specific objects, functions and functionality -from src.settings import SCREEN_WIDTH, SCREEN_HEIGHT, TILE_SIZE -import src.sprites - -# import inventory related classes -from src.GUI.inventory_gui import InventoryGUI -from src.GUI.inventory import Inventory - - -@dataclass -class GUI: - """Graphial User Interface vertion of the game, using pygame-ce""" - - screen_size: tuple[int, int] = (SCREEN_WIDTH, SCREEN_HEIGHT) - screen: pygame.Surface = field(init=False) - - # groups - # all_sprites: pygame.sprite.Group = field( - # init=False, default_factory=pygame.sprite.Group - # ) - - def __post_init__(self): - pygame.init() - self.screen = pygame.display.set_mode(self.screen_size) - pygame.display.set_caption("PySeas") - self.clock = pygame.Clock() - - # Initialize player inventory - self.player_inventory = Inventory() - self.inventory_gui = InventoryGUI(self.screen, self.player_inventory) - - # Load initial inventory items from JSON file - self.load_inventory_from_json("data/inventory.json") - - # Players list currently commented out; keeping for potential future use - # self.players: list[src.sprites.Player] = [src.sprites.Player()] - - self.all_sprites = src.sprites.AllSprites() - self.running = True - self.import_assets() - self.setup( - tmx_maps=self.tmx_map["map"], player_start_pos="top_left_island" - ) # The start positions will be one of the 4 islands in the corners of the board - - def import_assets(self): - """load the map""" - # The map was made as a basic start for the game, it can be changes or altered if it is better for the overall flow of the game - self.tmx_map = { - "map": load_pygame(join(".", "data", "maps", "100x100_map.tmx")) - } - - # # Define the path to the TMX file - # tmx_path = os.path.join('data', 'maps', '100x100_map.tmx') - # sprite_group = pygame.sprite.Group() - - # # Check if the file exists - # if not os.path.exists(self.tmx_maps): - # print(f"Error: The file at {self.tmx_maps} does not exist.") - # return None - - # # Load the TMX file using load_pygame - # tmx_data = load_pygame(tmx_path) - # print(tmx_data.layers) - - def setup(self, tmx_maps, player_start_pos): - """create tiles""" - - # Islands - islands = tmx_maps.get_layer_by_name("Islands") - for x, y, surface in islands.tiles(): - # print(x * TILE_SIZE, y * TILE_SIZE, surface) - src.sprites.Tile( - self.all_sprites, - pos=(x * TILE_SIZE, y * TILE_SIZE), - surf=surface, - ) - - # Objects - for obj in tmx_maps.get_layer_by_name("Ships"): - if obj.name == "Player" and obj.properties["pos"] == player_start_pos: - self.player = src.sprites.Player((obj.x, obj.y), self.all_sprites) - - def run(self) -> None: - """main loop of the game""" - while self.running: - self.handle_events() - self.render() - - def handle_events(self) -> None: - """get events like keypress or mouse clicks""" - for event in pygame.event.get(): - match event.type: - case pygame.QUIT: - pygame.quit() - sys.exit() - case pygame.KEYDOWN: - if event.key == pygame.K_i: # Toggle inventory with "I" key - self.toggle_inventory() - - def toggle_inventory(self): - """Toggle the inventory overlay.""" - self.inventory_gui.running = not self.inventory_gui.running - - while self.inventory_gui.running: - for event in pygame.event.get(): - if event.type == pygame.KEYDOWN and event.key == pygame.K_i: - self.inventory_gui.running = False # Close the inventory - elif ( - event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 - ): # Left click - self.inventory_gui.handle_mouse_click(event.pos) - elif event.type == pygame.MOUSEWHEEL: - self.inventory_gui.handle_events(event) - - self.inventory_gui.draw() - pygame.display.flip() # Update the display - - def load_inventory_from_json(self, file_path: str): - """Load initial inventory items from JSON file.""" - try: - with open(file_path, "r") as f: - items = json.load(f) - for item_name, properties in items.items(): - quantity = properties.get("quantity", 1) # Default to 1 if missing - self.player_inventory.add_item(item_name, quantity) - except (FileNotFoundError, json.JSONDecodeError): - print(f"Error: The file at {file_path} does not exist.") - - def render(self) -> None: - """draw sprites to the canvas""" - self.screen.fill("#000000") - self.all_sprites.update() - self.all_sprites.draw(self.player.rect.center, self.player.player_preview, self.player.player_preview_rect) - - '''No need to loop through the players because it is now in the sprite group AllSprites''' - # draw players on top of the other sprites - # for player in self.players: - # player.render(surface=self.screen) - - pygame.display.update() diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/game_manager.py b/src/game_manager.py new file mode 100644 index 0000000..2c82a9f --- /dev/null +++ b/src/game_manager.py @@ -0,0 +1,86 @@ +""" +main game loop +structure of the game, using a stack of states +""" + + +import sys +import pygame + +from src.settings import SCREEN_WIDTH, SCREEN_HEIGHT, FPS + +# import basestate for typehint +from src.states.base_state import BaseState +from src.states.game_running import GameRunning + + +class GameStateManager: + """ + Manages the main game loop and the structure of the game using a stack of states. + + This class is responsible for: + - Initializing Pygame and setting up the main screen. + - Managing a stack of game states, allowing for seamless transitions (e.g., from gameplay to paused state). + - Handling Pygame events and delegating them to the active state. + - Running the main game loop with controlled frame rate. + """ + def __init__(self) -> None: + + # init pygame + pygame.init() + self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + pygame.display.set_caption("PySeas") + + self.clock = pygame.Clock() + self.running = True + self.events: list[pygame.event.Event] = [] + self.states_stack: list[BaseState] = [] + + # instanciate the initial state + self.states_stack.append(GameRunning(self)) + + def __str__(self) -> str: + """ + return a string representing the stack + e.g. : >MainMenu>GameRunning>Paused + """ + stack_repr:str = "" + for state in self.states_stack: + stack_repr += '>' + str(state) + return stack_repr + + def enter_state(self, state: BaseState) -> None: + """ + append state to the stack + """ + self.states_stack.append(state) + + def exit_state(self) -> BaseState: + """ + pop and return the last state + """ + if len(self.states_stack) == 0: + raise ValueError("the stack is empty") + return self.states_stack.pop() + + def _handle_events(self): + self.events = pygame.event.get() + for event in self.events: + match event.type: + case pygame.QUIT: + pygame.quit() + sys.exit() + + def run(self) -> None: + """main loop of the game""" + while self.running: + self._handle_events() + + # give the pygame events to each states + # to ensure that pygame.event.get() is only called once per frame + self.states_stack[-1].update(self.events) + + self.states_stack[-1].render(self.screen) + + # magic value, use a FPS const in settings or delta time + self.clock.tick(FPS) diff --git a/src/GUI/inventory.py b/src/inventory.py similarity index 100% rename from src/GUI/inventory.py rename to src/inventory.py diff --git a/src/GUI/inventory_gui.py b/src/inventory_gui.py similarity index 99% rename from src/GUI/inventory_gui.py rename to src/inventory_gui.py index 2b39118..81f63c2 100644 --- a/src/GUI/inventory_gui.py +++ b/src/inventory_gui.py @@ -1,6 +1,6 @@ from typing import Dict, Tuple import pygame -from src.GUI.inventory import Inventory +from src.inventory import Inventory class InventoryGUI: diff --git a/src/settings.py b/src/settings.py index e157871..da849ba 100644 --- a/src/settings.py +++ b/src/settings.py @@ -5,6 +5,7 @@ SCREEN_WIDTH, SCREEN_HEIGHT = 1280, 720 TILE_SIZE = 16 +FPS = 60 if not getattr(pygame, "IS_CE", False): diff --git a/src/sprites.py b/src/sprites.py index 54827d9..9008089 100644 --- a/src/sprites.py +++ b/src/sprites.py @@ -3,7 +3,7 @@ import pygame from pygame import FRect from src.settings import TILE_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH -from src.GUI.inventory import Inventory +from src.inventory import Inventory # class Entity(pygame.sprite.Sprite): @@ -152,7 +152,7 @@ def __init__( There is a known typing error related to missing type parameters for the generic type 'Group' in pygame. You may see warnings like: - src\\sprites.py:107: error: Missing type parameters for generic type 'Group' [type-arg] - - src\\GUI\\gameloop.py:45: error: Missing type parameters for generic type 'SpriteGroup' [type-arg] + - src\\GUI\\game_manager.py:45: error: Missing type parameters for generic type 'SpriteGroup' [type-arg] These errors can be ignored for now, as they are related to type annotations in pygame's codebase. """ diff --git a/src/states/base_state.py b/src/states/base_state.py new file mode 100644 index 0000000..f3db083 --- /dev/null +++ b/src/states/base_state.py @@ -0,0 +1,33 @@ +""" +Represents a base example state in the game. +Each state must implement: +- `update`: A method that loops through and handles events. +- `render`: A method responsible for drawing the state on the given surface. +""" +from abc import ABC, abstractmethod +import pygame + + +class BaseState(ABC): + """ + using an abstract class to ensure each state has the right methods + """ + def __init__(self, game_state_manager) -> None: + self.game_state_manager = game_state_manager + + def __str__(self): + return self.__class__.__name__ + + @abstractmethod + def update(self, events): # return self + """ + update current state + handle events + and return current state or another one + """ + + @abstractmethod + def render(self, screen: pygame.Surface) -> None: + """ + render current state on a given surface + """ diff --git a/src/states/game_running.py b/src/states/game_running.py new file mode 100644 index 0000000..33cf9a4 --- /dev/null +++ b/src/states/game_running.py @@ -0,0 +1,97 @@ +""" +Represents the GameRunning state, where the player controls a ship and interacts with the game world. +""" +import os +import json +import pygame +from pytmx.util_pygame import load_pygame # type: ignore + +from src.states.base_state import BaseState +from src.states.paused import Paused +from src.inventory import Inventory + +from src.settings import TILE_SIZE +import src.sprites + + +class GameRunning(BaseState): + """ + Represents the GameRunning state, where the player controls a ship and interacts with the game world. + + Responsibilities: + - Loads the game map and player starting position. + - Manages player inventory. + - Updates game entities. + - Renders the game world on the screen. + """ + def __init__(self, game_state_manager) -> None: + super().__init__(game_state_manager) + + + # Initialize player inventory + self.player_inventory = Inventory() + self.load_inventory_from_json("data/inventory.json") + + self.all_sprites = src.sprites.AllSprites() + + # The start positions will be one of the 4 islands in the corners of the board + self.setup(player_start_pos="top_left_island") + + def setup(self, player_start_pos): + """ + setup the map and player from the tiled file + """ + self.tmx_map = { + "map": load_pygame(os.path.join(".", "data", "maps", "100x100_map.tmx")) + } + + # Islands + islands = self.tmx_map['map'].get_layer_by_name("Islands") + for x, y, surface in islands.tiles(): + src.sprites.Tile( + self.all_sprites, + pos=(x * TILE_SIZE, y * TILE_SIZE), + surf=surface, + ) + + # Objects + for obj in self.tmx_map['map'].get_layer_by_name("Ships"): + if obj.name == "Player" and obj.properties["pos"] == player_start_pos: + self.player = src.sprites.Player((obj.x, obj.y), self.all_sprites) + + def load_inventory_from_json(self, file_path: str): + """Load initial inventory items from JSON file.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + items = json.load(f) + for item_name, properties in items.items(): + quantity = properties.get("quantity", 1) # Default to 1 if missing + self.player_inventory.add_item(item_name, quantity) + except (FileNotFoundError, json.JSONDecodeError): + print(f"Error: The file at {file_path} does not exist.") + + def update(self, events) -> None: + """ + update each sprites and handle events + """ + + self.all_sprites.update() + + # get events like keypress or mouse clicks + for event in events: + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_i: # Toggle inventory with "I" key + self.game_state_manager.enter_state( + Paused(self.game_state_manager, self.player_inventory) + ) + + def render(self, screen) -> None: + """draw sprites to the canvas""" + screen.fill("#000000") + self.all_sprites.draw( + self.player.rect.center, + self.player.player_preview, + self.player.player_preview_rect + ) + + pygame.display.update() diff --git a/src/states/paused.py b/src/states/paused.py new file mode 100644 index 0000000..8565ca1 --- /dev/null +++ b/src/states/paused.py @@ -0,0 +1,217 @@ +""" +paused state +holding the inventory +""" +from typing import Dict, Tuple + +import pygame +from src.states.base_state import BaseState +from src.inventory import Inventory # for typehints + +from src.settings import SCREEN_WIDTH, SCREEN_HEIGHT + + +class Paused(BaseState): + """ + paused state + holding the inventory + """ + def __init__(self, game_state_manager, inventory: Inventory) -> None: + super().__init__(game_state_manager) + + self.inventory = inventory + self.font = pygame.font.Font(None, 36) + self.running = True + + # self.screen is a temp surface, blitted to the display after the rendering + self.screen = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) + + # Scrolling inventory + self.scroll_offset = 0 + self.max_visible_items = 10 + self.item_height = 60 + + # Load sprite sheet and extract the icons (Testing purposes) + # To be replaced when: + # 1) Spritesheet has been decide. + # 2) A 'Buy', 'Found' or 'Add' in-game feature has been implemented + self.sprite_sheet = pygame.image.load( + "images/tilesets/Treasure+.png" + ).convert_alpha() + self.icons = { + "Gold Coin": self.extract_icon(0, 0), + "Silver Coin": self.extract_icon(16, 0), + "Coin Stack (1)": self.extract_icon(32, 0), + "Coin Stack (2)": self.extract_icon(48, 0), + "Circular Gem": self.extract_icon(64, 0), + "Single Gold Bar": self.extract_icon(0, 16), + "Gold Bar Stack": self.extract_icon(16, 16), + "Treasure Block": self.extract_icon(32, 16), + "Golden Crown": self.extract_icon(0, 32), + "Ornate Cup": self.extract_icon(16, 32), + "Golden Figurine": self.extract_icon(32, 32), + "Simple Sword": self.extract_icon(0, 48), + "Ornate Sword": self.extract_icon(16, 48), + "Double-Bladed Axe": self.extract_icon(32, 48), + "Spear": self.extract_icon(48, 48), + "Circular Shield": self.extract_icon(64, 48), + "Golden Trophy": self.extract_icon(0, 64), + "Candelabra": self.extract_icon(16, 64), + "Potion (Red)": self.extract_icon(0, 80), + "Potion (Blue)": self.extract_icon(16, 80), + "Potion (Green)": self.extract_icon(32, 80), + "Square Jar": self.extract_icon(48, 80), + "Cake": self.extract_icon(0, 96), + "Donut": self.extract_icon(16, 96), + "Bread": self.extract_icon(32, 96), + "Rug Tile": self.extract_icon(0, 112), + "Geometric Pattern": self.extract_icon(16, 112), + "Glowing Orb (Blue)": self.extract_icon(0, 128), + "Glowing Orb (Red)": self.extract_icon(16, 128), + "Glowing Orb (Green)": self.extract_icon(32, 128), + "Golden Ring": self.extract_icon(48, 128), + "Amulet": self.extract_icon(64, 128), + "Scroll": self.extract_icon(0, 144), + "Key": self.extract_icon(16, 144), + "Tool": self.extract_icon(32, 144), + "Dragon (Red)": self.extract_icon(0, 160), + "Dragon (Green)": self.extract_icon(16, 160), + "Dragon (Black)": self.extract_icon(32, 160), + "Dragon (White)": self.extract_icon(48, 160), + "Gem Cluster": self.extract_icon(0, 176), + "Glowing Crystal": self.extract_icon(16, 176), + } + + # Button dimmentions + self.button_width = 100 + self.button_height = 50 + + # Initialize button actions + self.button_actions: Dict[str, Tuple[pygame.Rect, pygame.Rect]] = {} + + # Action messages + self.message = "" + self.message_end_time = 0 # Time to display the message + + def handle_mouse_click(self, mouse_pos) -> None: + """Handle mouse clicks on buttons.""" + for item, (use_button, discard_button) in self.button_actions.items(): + if use_button.collidepoint(mouse_pos): + self.message = self.inventory.use_item(item) # `self.message` stores strings + self.message_end_time = pygame.time.get_ticks() + 3000 # 3 seconds + elif discard_button.collidepoint(mouse_pos): + self.message = self.inventory.remove_item(item, 1) + self.message_end_time = pygame.time.get_ticks() + 4000 # 4 seconds + + def extract_icon(self, x, y, size=16): + """Extract a single icon from the sprite sheet.""" + return self.sprite_sheet.subsurface((x, y, size, size)) + + def draw_buttons(self, x: int, y: int, item: str) -> Tuple[pygame.Rect, pygame.Rect]: + """Draw Use and Discard buttons for a specific item.""" + use_button = pygame.Rect(x, y, self.button_width, self.button_height) + discard_button = pygame.Rect( + x + self.button_width + 10, y, self.button_width, self.button_height + ) + + pygame.draw.rect(self.screen, (0, 255, 0), use_button) # Green + pygame.draw.rect(self.screen, (150, 75, 0), discard_button) # Brown + + use_text = self.font.render("Use", True, (0, 0, 0)) # Black + discard_text = self.font.render("Discard", True, (0, 0, 0)) + + self.screen.blit(use_text, (x + 10, y + 10)) + self.screen.blit(discard_text, (x + self.button_width + 20, y + 10)) + + return use_button, discard_button + + def update(self, events): + """ + handle key press and mouse scroll + """ + for event in events: + match event.type: + case pygame.KEYDOWN: + if event.key == pygame.K_i: + self.game_state_manager.exit_state() + case pygame.MOUSEBUTTONDOWN: + if event.button == 1: + self.handle_mouse_click(event.pos) + case pygame.MOUSEWHEEL: + self.scroll_offset = max(0, self.scroll_offset - event.y) + max_offset = max( + 0, len(self.inventory.get_items()) - self.max_visible_items + ) + self.scroll_offset = min(self.scroll_offset, max_offset) + + def render(self, screen: pygame.Surface) -> None: + """Draw the inventory overlay.""" + + self.screen.fill((0, 0, 0)) # Solid Black background + + # Reset button actions + self.button_actions = {} + + # Draw the inventory items + items = list(self.inventory.get_items().items()) + visible_items = items[ + self.scroll_offset : self.scroll_offset + self.max_visible_items + ] + y_offset = 50 # Start below the title + + for item, quantity in visible_items: + # Draw icon + if item in self.icons: + self.screen.blit(self.icons[item], (50, y_offset)) + + # Draw quantity next to the icon + quantity_text = self.font.render(f"x{quantity}", True, (255, 255, 255)) + self.screen.blit(quantity_text, (100, y_offset + 5)) + + # Draw item name (move text to the right) + text = self.font.render(item, True, (255, 255, 255)) + self.screen.blit(text, (150, y_offset)) + + # Draw buttons + use_button, discard_button = self.draw_buttons(400, y_offset, item) + + # Store button references for event handling + self.button_actions[item] = (use_button, discard_button) + y_offset += 60 # Move down for the next item + + # Draw hint + hint_text = self.font.render( + "Press 'I' to close inventory", True, (200, 200, 200) + ) # Light gray text + self.screen.blit(hint_text, (50, self.screen.get_height() - 60)) + + # Display action message above the hint + if self.message and pygame.time.get_ticks() < self.message_end_time: + # Render the message text + message_text = self.font.render(self.message, True, (255, 255, 0)) # Yellow + + # Measure the message text size + text_width, text_height = message_text.get_size() + + # Message background + message_bg_x = 40 + message_bg_y = self.screen.get_height() - 120 + message_bg_width = text_width + 20 # Add padding + message_bg_height = text_height + 10 # Add padding + + # Draw background rectangle for the message + pygame.draw.rect( + self.screen, + (0, 0, 0), # Black background + (message_bg_x, message_bg_y, message_bg_width, message_bg_height), + ) + + # Draw the message text on top of the background + self.screen.blit( + message_text, + (message_bg_x + 10, message_bg_y + 5), # Position text with padding + ) + + # blit tmp self.screen to the actual display (screen form the argument) + screen.blit(self.screen, dest=(0, 0)) + pygame.display.flip() # Update the display diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000