diff --git a/.github/workflows/lint_and_typehints.yml b/.github/workflows/lint_and_typehints.yml new file mode 100644 index 0000000..ce7bbc1 --- /dev/null +++ b/.github/workflows/lint_and_typehints.yml @@ -0,0 +1,16 @@ +name: Lint and Typehints + +on: + push: + branches: + - main + pull_request: + types: [ opened, synchronize, reopened ] + +jobs: + lint_and_typehints: + uses: PyCeas/.github/.github/workflows/lint_and_test.yml@main + with: + requirements_path: 'requirements_dev.txt' # Optional: Path to requirements file + secrets: + repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 82f9275..7b6caf3 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/README.md b/README.md index 0bbe077..6cad685 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Discord](https://discord.com/api/guilds/1272287320934056066/widget.png)](https://discord.gg/s2P9fZbeZs) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://www.freecodecamp.org/news/how-to-make-your-first-pull-request-on-github-3/) -[![License](https://img.shields.io/github/license/ultimateownsz/PyCeas)](https://github.com/PyCeas/Pyceas/blob/main/LICENSE) +[![License](https://img.shields.io/github/license/PyCeas/PyCeas)](https://github.com/PyCeas/Pyceas/blob/main/LICENSE) ## Table of Contents - [PyCeas - Open Source Pirate (Adventure) Board Game](#pyceas---open-source-pirate-adventure-board-game) @@ -11,6 +11,12 @@ - [Game Design Document](#game-design-document) - [Why Join PyCeas?](#why-join-pyceas) - [Getting Started](#getting-started) + - [Clone the Repository](#clone-the-repository) + - [Install Python](#install-python) + - [Set up a virtual environment](#set-up-a-virtual-environment) + - [Install Required Software](#install-required-software) + - [Run the project](#run-the-project) + - [Deactivate the Virtual Environment:](#deactivate-the-virtual-environment) - [Reporting bugs \& requesting features](#reporting-bugs--requesting-features) - [Local Development](#local-development) - [Linting and Formatting for developers](#linting-and-formatting-for-developers) @@ -37,11 +43,7 @@ Every game needs thorough documentation, and you can find our Game Design Docume - **Create something enjoyable**: Contributing to a project that results in a game you and others can enjoy. - **Join our community on [Discord](https://discord.gg/MZ5MHqDnGW)**: Whether you want to help with ideas, give feedback, or simply enjoy the game, everyone is welcome! -## Getting Started - -No need to worry if you are new to programming. This guide will walk you through the setup step by step. by the end, you'll have everything ready to run the PyCeas project. - -> [!IMPORTANT] +> [!NOTE] > > This project was previously known as **PySeas** and has been renamed to **PyCeas**. > If you have already cloned the repository under the old name, update your local repository’s remote URL: @@ -50,17 +52,59 @@ No need to worry if you are new to programming. This guide will walk you through > git remote set-url origin https://github.com/PyCeas/Pyceas.git > ``` +## Getting Started -1. **Clone the Repository:** +No need to worry if you are new to programming. This guide will walk you through the setup step by step. by the end, you'll have everything ready to run the PyCeas project. + + +### Clone the Repository First you'll need to copy the PyCeas project to your computer through a process called "cloning". ``` git clone https://github.com/PyCeas/Pyceas.git ``` -2. **Set up a virtual environment:** +### Install Python +Check if Python is installed on your system. You can check by running the following command in your terminal prompt: + +- ##### For Mac Users: + 1. Open the terminal and install Python using `Homebrew`. If Homebrew is not installed, [follow these instructions](https://brew.sh/): + ```bash + brew install python3 + ``` + 1. After installation, verify it by running: + ```bash + python3 --version + ``` + +- ##### For Linux Users + 1. Open the terminal and update your package list: + ```bash + sudo apt update + ``` + 2. Install Python + ```bash + sudo apt install python3 + ``` + 3. After installation, verify it by running: + ```bash + python3 --version + ``` + +- ##### For Windows Users + 1. Download the Python installer from the [official Python website](https://www.python.org/downloads/). + 2. Run the installer and follow the on-screen instructions. + 3. Make sure to check the box "Add Python to PATH" before clicking "Install Now". + 4. After installation, verify by running: + ```bash + python --version + ``` + + +### Set up a virtual environment A virtual environment is like a seperate space on your computer where you can install the software needed for this project without affecting other programs. -- **For Mac or Linux Users:** + +- ##### For Mac or Linux Users: - In your terminal, navigate to the folder where you downloaded the project (usually the 'PyCeas' folder) using the 'cd' command: ```bash cd PyCeas @@ -74,43 +118,43 @@ A virtual environment is like a seperate space on your computer where you can in source venv/bin/activate ``` -- **For Windows Users**: +- ##### For Windows Users: - Open Command Prompt and navigate to the 'PyCeas' folder (where you downloaded the project) using the 'cd' command: ```bash cd PyCeas ``` - Set up the virtual environment by typing: ```bash - python3 -m venv venv + python -m venv venv ``` - Activate the virtual environment: ```bash venv\Scripts\activate ``` -3. **Install Required Software**: +### Install Required Software Now, you'll need to install the necessary software that the project depends on. - Make sure you're still in the 'PyCeas' directory/folder and that the virtual environment is active. - Install the software by typing the following command: - ``` + ```bash pip install -r requirements.txt # For running the game (runtime dependencies) ``` This installs everything you need to run the project. - If you plan to do any local development or modifications, also run: - ``` + ```bash pip install -r requirements_dev.txt # For local development ``` This step is optional and only needed if you want to make changes to the project. -4. **Run the project** +### Run the project Now you are ready to start the project! - Simply type: - ``` + ```bash python main.py ``` - The project should start running, and you'll see it in action! -**Deactivate the Virtual Environment**: +### Deactivate the Virtual Environment: When you’re done working, you can deactivate the virtual environment using: ```bash deactivate diff --git a/main.py b/main.py index e781854..2f48b5d 100644 --- a/main.py +++ b/main.py @@ -24,7 +24,6 @@ # import Pygame specific objects, functions and functionality from src.game_manager import GameStateManager - if __name__ == "__main__": game = GameStateManager() game.run() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..063326e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[tool.ruff] +line-length = 120 +lint.select = ["E", "F", "I"] +lint.fixable = ["F", "I"] +exclude = ["build/", "dist/", ".venv/"] + +[tool.mypy] +python_version = "3.12" +check_untyped_defs = true +ignore_missing_imports = true +explicit_package_bases = true + +["mypy-pygame.*"] +ignore_missing_imports = true diff --git a/src/GUI/gameloop.py b/src/GUI/gameloop.py index 5828ec4..d30f12f 100644 --- a/src/GUI/gameloop.py +++ b/src/GUI/gameloop.py @@ -1,125 +1,155 @@ -from os.path import join -import sys - -# 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, WORLD_LAYERS -from src.support import import_folder, coast_importer, all_character_import -import src.sprites -from src.sprites import AnimatedSprites - - -@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() - - # 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 - self.camera_mode = "drag" - - 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", "new_maps", "100x100_map.tmx")) - } - - self.world_frames = { - "water": import_folder(".", "images", "tilesets", "water"), - "coast": coast_importer(6, 6, ".", "images", "tilesets", "coast"), - "ships": all_character_import(".", "images", "tilesets", "ships") - } - # print(self.world_frames["ships"]) - - def setup(self, tmx_maps, player_start_pos): - """create tiles""" - - # Sea - for x, y, surface in tmx_maps.get_layer_by_name("Sea").tiles(): - src.sprites.Sprite((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, WORLD_LAYERS["bg"]) - - # Water animated - for obj in tmx_maps.get_layer_by_name("Water"): - for x in range(int(obj.x), int(obj.x + obj.width), TILE_SIZE): - for y in range(int(obj.y), int(obj.y + obj.height), TILE_SIZE): - AnimatedSprites((x, y), self.world_frames["water"], self.all_sprites, WORLD_LAYERS["water"]) - - # Shallow water - for x, y, surface in tmx_maps.get_layer_by_name("Shallow Sea").tiles(): - src.sprites.Sprite((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, WORLD_LAYERS["bg"]) - - # Islands - islands = tmx_maps.get_layer_by_name("Islands") - for x, y, surface in islands.tiles(): - src.sprites.Sprite((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, WORLD_LAYERS["bg"]) - - # Enitites - 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( - pos = (obj.x, obj.y), - frames = self.world_frames["ships"]["player_test_ship"], - groups = self.all_sprites) - - # Coast - for obj in tmx_maps.get_layer_by_name("Coast"): - terrain = obj.properties["terrain"] - side = obj.properties["side"] - AnimatedSprites((obj.x, obj.y), self.world_frames["coast"][terrain][side], self.all_sprites, WORLD_LAYERS["bg"]) - - - 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() - - def render(self) -> None: - """draw sprites to the canvas""" - dt = self.clock.tick() / 1000 - self.screen.fill("#000000") - - self.all_sprites.update(dt) - 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() - +# import sys + +# # import dataclasses and typchecking +# from dataclasses import dataclass, field +# from os.path import join + +# # import pygame related +# import pygame +# from pytmx.util_pygame import load_pygame # type: ignore + +# import src.sprites + +# # import Pygame specific objects, functions and functionality +# from src.settings import SCREEN_HEIGHT, SCREEN_WIDTH, TILE_SIZE, WORLD_LAYERS +# from src.sprites import AnimatedSprites +# from src.support import all_character_import, coast_importer, import_folder + + +# @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() + +# # 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 +# self.camera_mode = "drag" + +# 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", "new_maps", "100x100_map.tmx")) +# } + +# self.world_frames = { +# "water": import_folder(".", "images", "tilesets", "water"), +# "coast": coast_importer(6, 6, ".", "images", "tilesets", "coast"), +# "ships": all_character_import(".", "images", "tilesets", "ships"), +# } +# # print(self.world_frames["ships"]) + +# def setup(self, tmx_maps, player_start_pos): +# """create tiles""" + +# # Sea +# for x, y, surface in tmx_maps.get_layer_by_name("Sea").tiles(): +# src.sprites.Sprite( +# (x * TILE_SIZE, y * TILE_SIZE), +# surface, +# self.all_sprites, +# WORLD_LAYERS["bg"], +# ) + +# # Water animated +# for obj in tmx_maps.get_layer_by_name("Water"): +# for x in range(int(obj.x), int(obj.x + obj.width), TILE_SIZE): +# for y in range(int(obj.y), int(obj.y + obj.height), TILE_SIZE): +# AnimatedSprites( +# (x, y), +# self.world_frames["water"], +# self.all_sprites, +# WORLD_LAYERS["water"], +# ) + +# # Shallow water +# for x, y, surface in tmx_maps.get_layer_by_name("Shallow Sea").tiles(): +# src.sprites.Sprite( +# (x * TILE_SIZE, y * TILE_SIZE), +# surface, +# self.all_sprites, +# WORLD_LAYERS["bg"], +# ) + +# # Islands +# islands = tmx_maps.get_layer_by_name("Islands") +# for x, y, surface in islands.tiles(): +# src.sprites.Sprite( +# (x * TILE_SIZE, y * TILE_SIZE), +# surface, +# self.all_sprites, +# WORLD_LAYERS["bg"], +# ) + +# # Enitites +# 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( +# pos=(obj.x, obj.y), +# frames=self.world_frames["ships"]["player_test_ship"], +# groups=self.all_sprites, +# ) + +# # Coast +# for obj in tmx_maps.get_layer_by_name("Coast"): +# terrain = obj.properties["terrain"] +# side = obj.properties["side"] +# AnimatedSprites( +# (obj.x, obj.y), +# self.world_frames["coast"][terrain][side], +# self.all_sprites, +# WORLD_LAYERS["bg"], +# ) + +# 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() + +# def render(self) -> None: +# """draw sprites to the canvas""" +# dt = self.clock.tick() / 1000 +# self.screen.fill("#000000") + +# self.all_sprites.update(dt) +# 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/game_manager.py b/src/game_manager.py index a02072f..a3fc152 100644 --- a/src/game_manager.py +++ b/src/game_manager.py @@ -4,9 +4,10 @@ """ import sys -import pygame -from src.settings import SCREEN_WIDTH, SCREEN_HEIGHT, FPS +import pygame # type: ignore + +from src.settings import FPS, SCREEN_HEIGHT, SCREEN_WIDTH # import basestate for typehint from src.states.base_state import BaseState diff --git a/src/inventory.py b/src/inventory.py index e1591c1..5cd5d31 100644 --- a/src/inventory.py +++ b/src/inventory.py @@ -39,14 +39,10 @@ def add_item(self, item_name: str, quantity: int) -> str: """Add an item to the inventory""" if item_name in self.items: self.items[item_name] += quantity - return get_message( - "inventory", "add_success", item=item_name, quantity=quantity - ) + return get_message("inventory", "add_success", item=item_name, quantity=quantity) else: self.items[item_name] = quantity - return get_message( - "inventory", "add_success", item=item_name, quantity=quantity - ) + return get_message("inventory", "add_success", item=item_name, quantity=quantity) def remove_item(self, item_name: str, quantity: int) -> str: """Remove an item from the inventory. Return True if successful.""" @@ -54,18 +50,12 @@ def remove_item(self, item_name: str, quantity: int) -> str: self.items[item_name] -= quantity if self.items[item_name] == 0: del self.items[item_name] - return get_message( - "inventory", "remove_success", item=item_name, quantity=quantity - ) - return get_message( - "inventory", "remove_fail", item=item_name, quantity=quantity - ) + return get_message("inventory", "remove_success", item=item_name, quantity=quantity) + return get_message("inventory", "remove_fail", item=item_name, quantity=quantity) def use_item(self, item_name: str) -> str: """Use an item, applying its effect. Return a message.""" - if self.remove_item(item_name, 1) == get_message( - "inventory", "remove_success", item=item_name, quantity=1 - ): + if self.remove_item(item_name, 1) == get_message("inventory", "remove_success", item=item_name, quantity=1): return get_message("inventory", "use_success", item=item_name) return get_message("inventory", "use_fail", item=item_name) diff --git a/src/inventory_gui.py b/src/inventory_gui.py index abb1776..4231309 100644 --- a/src/inventory_gui.py +++ b/src/inventory_gui.py @@ -1,5 +1,7 @@ from typing import Dict, Tuple -import pygame + +import pygame # type: ignore + from src.inventory import Inventory @@ -20,9 +22,7 @@ def __init__(self, screen: pygame.Surface, inventory: Inventory) -> None: # 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.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), @@ -83,23 +83,17 @@ def handle_events(self, event): if event.type == pygame.MOUSEWHEEL: # Adjust scroll offset self.scroll_offset = max(0, self.scroll_offset - event.y) - max_offset = max( - 0, len(self.inventory.get_items()) - self.max_visible_items - ) + max_offset = max(0, len(self.inventory.get_items()) - self.max_visible_items) self.scroll_offset = min(self.scroll_offset, max_offset) 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]: + 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 - ) + 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 @@ -121,9 +115,7 @@ def draw(self): # Draw the inventory items items = list(self.inventory.get_items().items()) - visible_items = items[ - self.scroll_offset : self.scroll_offset + self.max_visible_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: @@ -147,9 +139,7 @@ def draw(self): 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 + 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 @@ -183,9 +173,7 @@ 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 = 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) diff --git a/src/settings.py b/src/settings.py index f46d776..34f9b4f 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,25 +1,21 @@ import sys import warnings -import pygame -import pygame.freetype + +import pygame # type: ignore +import pygame.freetype # type: ignore SCREEN_WIDTH, SCREEN_HEIGHT = 1280, 720 TILE_SIZE = 16 ANIMATION_SPEED = 4 -WORLD_LAYERS = { - "water": 0, - "bg": 1, - "main": 2, - "top": 3 -} +WORLD_LAYERS = {"water": 0, "bg": 1, "main": 2, "top": 3} FPS = 60 +# For some imports like pygame.freetype, Mypy can't infer the type of this attribute, so we suppress the error. if not getattr(pygame, "IS_CE", False): raise ImportError( - "The game requires Pygame CE to function. " - "(hint: type pip uninstall pygame and then pip install pygame-ce)" + "The game requires Pygame CE to function. (hint: type pip uninstall pygame and then pip install pygame-ce)" ) if sys.version_info < (3, 12): diff --git a/src/sprites.py b/src/sprites.py index 6592fce..24ba2fd 100644 --- a/src/sprites.py +++ b/src/sprites.py @@ -1,9 +1,16 @@ """custom sprites classes""" -import pygame +import pygame # type: ignore from pygame import FRect -from src.settings import TILE_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH, ANIMATION_SPEED, WORLD_LAYERS + from src.inventory import Inventory +from src.settings import ( + ANIMATION_SPEED, + SCREEN_HEIGHT, + SCREEN_WIDTH, + TILE_SIZE, + WORLD_LAYERS, +) class Entity(pygame.sprite.Sprite): @@ -23,7 +30,7 @@ def __init__(self, pos, frames, groups): self.image = pygame.Surface((TILE_SIZE, TILE_SIZE)) self.image.fill("red") # self.image = self.frames[self.get_state()][self.frame_index] - self.rect = self.image.get_frect(center = pos) + self.rect = self.image.get_frect(center=pos) def animate(self, dt): self.frame_index += ANIMATION_SPEED * dt @@ -36,7 +43,7 @@ def get_state(self): self.facing_direction = "right" if self.direction.x > 0 else "left" if self.direction.y != 0: self.facing_direction = "down" if self.direction.x > 0 else "up" - return f"{self.facing_direction}{"" if moving else "_idle"}" + return f"{self.facing_direction}{'' if moving else '_idle'}" class AllSprites(pygame.sprite.Group): @@ -48,31 +55,56 @@ def __init__(self): self.display_surface = pygame.display.get_surface() if not self.display_surface: raise ValueError("Display surface is not initialized") - + self.offset = pygame.math.Vector2() self.scale = 2.0 def draw(self, player_center): + # Calculate offsets self.offset.x = -(player_center[0] * self.scale - SCREEN_WIDTH / 2) self.offset.y = -(player_center[1] * self.scale - SCREEN_HEIGHT / 2) + # Separate sprites into layers background_sprites = [sprite for sprite in self if sprite.z < WORLD_LAYERS["main"]] main_sprites = [sprite for sprite in self if sprite.z == WORLD_LAYERS["main"]] foreground_sprites = [sprite for sprite in self if sprite.z > WORLD_LAYERS["main"]] + # Render each layer for layer in (background_sprites, main_sprites, foreground_sprites): for sprite in layer: - scaled_image = pygame.transform.scale(sprite.image, - (int(sprite.rect.width * self.scale), int(sprite.rect.height * self.scale))) - scaled_rect = scaled_image.get_rect(center=(sprite.rect.center[0] * self.scale, sprite.rect.center[1] * self.scale)) - scaled_rect.topleft += self.offset - + # Scale the image + scaled_image = pygame.transform.scale( + sprite.image, + ( + int(sprite.rect.width * self.scale), + int(sprite.rect.height * self.scale), + ), + ) + + # Adjust the rect to the new scale + scaled_rect = scaled_image.get_rect( + center=( + int(sprite.rect.center[0] * self.scale), + int(sprite.rect.center[1] * self.scale), + ) + ) + + # Add offset to the rect position + scaled_rect.topleft = ( + scaled_rect.topleft[0] + int(self.offset.x), + scaled_rect.topleft[1] + int(self.offset.y), + ) + + # Ensure display_surface is valid before blitting + if self.display_surface is None: + raise ValueError("self.display_surface cannot be None") self.display_surface.blit(scaled_image, scaled_rect.topleft) # scaling of the ghost preview - # scaled_preview = pygame.transform.scale(player_preview, - # (int(player_preview_rect.width * self.scale), int(player_preview_rect.height * self.scale))) - # scaled_preview_rect = scaled_preview.get_rect(center=(player_preview_rect.center[0] * self.scale, player_preview_rect.center[1] * self.scale)) + # scaled_preview = pygame.transform.scale(player_preview, + # (int(player_preview_rect.width * self.scale), int(player_preview_rect.height * self.scale))) + # scaled_preview_rect = scaled_preview.get_rect(center=( + # player_preview_rect.center[0] * self.scale, player_preview_rect.center[1] * self.scale)) # scaled_preview_rect.topleft += self.offset # self.display_surface.blit(scaled_preview, scaled_preview_rect.topleft) @@ -90,13 +122,16 @@ class Player(Entity): def __init__(self, pos, frames, groups): super().__init__(pos, frames, groups) + # Ensure self.image is not None + if self.image is None: + raise ValueError("self.image cannot be None") + # ghost preview self.player_preview = self.image.copy() self.player_preview.set_alpha(128) self.inventory = Inventory() self.mouse_have_been_pressed: bool = False - self.draggin = False self.offset_x = 0 self.offset_y = 0 @@ -208,16 +243,14 @@ def __init__( self.image: pygame.Surface = self.surf self.rect: pygame.FRect = self.image.get_frect(topleft=self.pos) - def draw( - self, display_surface: pygame.Surface, offset: tuple[float, float] = (0, 0) - ) -> None: + def draw(self, display_surface: pygame.Surface, offset: tuple[float, float] = (0, 0)) -> None: """Could be useful for a camera?""" offset_rect = self.rect.move(offset) display_surface.blit(self.image, offset_rect) class Sprite(pygame.sprite.Sprite): - def __init__(self, pos, surf, groups, z = WORLD_LAYERS["main"]): + def __init__(self, pos: tuple[int, int], surf: pygame.Surface, groups, z=WORLD_LAYERS["main"]): super().__init__(groups) self.image = surf self.rect = self.image.get_frect(topleft=pos) @@ -225,7 +258,7 @@ def __init__(self, pos, surf, groups, z = WORLD_LAYERS["main"]): class AnimatedSprites(Sprite): - def __init__(self, pos, frames, groups, z = WORLD_LAYERS["main"]): + def __init__(self, pos, frames, groups, z=WORLD_LAYERS["main"]): self.frame_index, self.frames = 0, frames super().__init__(pos, frames[self.frame_index], groups, z) diff --git a/src/states/base_state.py b/src/states/base_state.py index 94c892e..6217f6a 100644 --- a/src/states/base_state.py +++ b/src/states/base_state.py @@ -6,7 +6,8 @@ """ from abc import ABC, abstractmethod -import pygame + +import pygame # type: ignore class BaseState(ABC): diff --git a/src/states/game_running.py b/src/states/game_running.py index 3edaa62..2faeff2 100644 --- a/src/states/game_running.py +++ b/src/states/game_running.py @@ -2,19 +2,19 @@ Represents the GameRunning state, where the player controls a ship and interacts with the game world. """ -import os import json -import pygame +import os + +import pygame # type: ignore from pytmx.util_pygame import load_pygame # type: ignore -from src.states.base_state import BaseState -from src.states.paused import Paused +import src.sprites from src.inventory import Inventory -from src.support import import_folder, coast_importer, all_character_import -from src.sprites import AnimatedSprites - from src.settings import TILE_SIZE, WORLD_LAYERS -import src.sprites +from src.sprites import AnimatedSprites +from src.states.base_state import BaseState +from src.states.paused import Paused +from src.support import all_character_import, coast_importer, import_folder class GameRunning(BaseState): @@ -45,48 +45,72 @@ 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", "new_maps", "100x100_map.tmx")) - } + self.tmx_map = {"map": load_pygame(os.path.join(".", "data", "new_maps", "100x100_map.tmx"))} self.world_frames = { "water": import_folder(".", "images", "tilesets", "temporary_water"), "coast": coast_importer(6, 6, ".", "images", "tilesets", "coast"), - "ships": all_character_import(".", "images", "tilesets", "ships") + "ships": all_character_import(".", "images", "tilesets", "ships"), } # Sea for x, y, surface in self.tmx_map["map"].get_layer_by_name("Sea").tiles(): - src.sprites.Sprite((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, WORLD_LAYERS["bg"]) + src.sprites.Sprite( + (x * TILE_SIZE, y * TILE_SIZE), + surface, + self.all_sprites, + WORLD_LAYERS["bg"], + ) # Water animated for obj in self.tmx_map["map"].get_layer_by_name("Water"): for x in range(int(obj.x), int(obj.x + obj.width), TILE_SIZE): for y in range(int(obj.y), int(obj.y + obj.height), TILE_SIZE): - AnimatedSprites((x, y), self.world_frames["water"], self.all_sprites, WORLD_LAYERS["water"]) + AnimatedSprites( + (x, y), + self.world_frames["water"], + self.all_sprites, + WORLD_LAYERS["water"], + ) # Shallow water for x, y, surface in self.tmx_map["map"].get_layer_by_name("Shallow Sea").tiles(): - src.sprites.Sprite((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, WORLD_LAYERS["bg"]) + src.sprites.Sprite( + (x * TILE_SIZE, y * TILE_SIZE), + surface, + self.all_sprites, + WORLD_LAYERS["bg"], + ) # Islands islands = self.tmx_map["map"].get_layer_by_name("Islands") for x, y, surface in islands.tiles(): - src.sprites.Sprite((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, WORLD_LAYERS["bg"]) + src.sprites.Sprite( + (x * TILE_SIZE, y * TILE_SIZE), + surface, + self.all_sprites, + WORLD_LAYERS["bg"], + ) # Enitites 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( - pos = (obj.x, obj.y), - frames = self.world_frames["ships"]["player_test_ship"], - groups = self.all_sprites) + pos=(obj.x, obj.y), + frames=self.world_frames["ships"]["player_test_ship"], + groups=self.all_sprites, + ) # Coast for obj in self.tmx_map["map"].get_layer_by_name("Coast"): terrain = obj.properties["terrain"] side = obj.properties["side"] - AnimatedSprites((obj.x, obj.y), self.world_frames["coast"][terrain][side], self.all_sprites, WORLD_LAYERS["bg"]) + AnimatedSprites( + (obj.x, obj.y), + self.world_frames["coast"][terrain][side], + self.all_sprites, + WORLD_LAYERS["bg"], + ) def load_inventory_from_json(self, file_path: str): """Load initial inventory items from JSON file.""" @@ -110,15 +134,11 @@ def update(self, events) -> None: 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) - ) + 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.all_sprites.draw(self.player.rect.center) pygame.display.update() diff --git a/src/states/paused.py b/src/states/paused.py index 5711b96..f95283d 100644 --- a/src/states/paused.py +++ b/src/states/paused.py @@ -5,11 +5,11 @@ from typing import Dict, Tuple -import pygame -from src.states.base_state import BaseState -from src.inventory import Inventory # for typehints +import pygame # type: ignore -from src.settings import SCREEN_WIDTH, SCREEN_HEIGHT +from src.inventory import Inventory # for typehints +from src.settings import SCREEN_HEIGHT, SCREEN_WIDTH +from src.states.base_state import BaseState class Paused(BaseState): @@ -37,9 +37,7 @@ def __init__(self, game_state_manager, inventory: Inventory) -> None: # 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.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), @@ -99,9 +97,7 @@ 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 = 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) @@ -111,14 +107,10 @@ 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]: + 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 - ) + 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 @@ -145,9 +137,7 @@ def update(self, events): 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 - ) + 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: @@ -160,9 +150,7 @@ def render(self, screen: pygame.Surface) -> None: # Draw the inventory items items = list(self.inventory.get_items().items()) - visible_items = items[ - self.scroll_offset : self.scroll_offset + self.max_visible_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: @@ -186,9 +174,7 @@ def render(self, screen: pygame.Surface) -> None: 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 + 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 diff --git a/src/support.py b/src/support.py index 3b56490..548302b 100644 --- a/src/support.py +++ b/src/support.py @@ -1,80 +1,96 @@ -import pygame -from os.path import join from os import walk +from os.path import join + +import pygame # type: ignore + # from pytmx.util_pygame import load_pygame -# imports -def import_image(*path, alpha = True, format = 'png'): - full_path = join(*path) + f'.{format}' - surf = pygame.image.load(full_path).convert_alpha() if alpha else pygame.image.load(full_path).convert() - return surf + +# imports +def import_image(*path, alpha=True, format="png"): + full_path = join(*path) + f".{format}" + surf = pygame.image.load(full_path).convert_alpha() if alpha else pygame.image.load(full_path).convert() + return surf + def import_folder(*path): - frames = [] - for folder_path, sub_folders, image_names in walk(join(*path)): - for image_name in sorted(image_names, key = lambda name: int(name.split('.')[0])): - full_path = join(folder_path, image_name) - surf = pygame.image.load(full_path).convert_alpha() - frames.append(surf) - return frames + frames = [] + for folder_path, sub_folders, image_names in walk(join(*path)): + for image_name in sorted(image_names, key=lambda name: int(name.split(".")[0])): + full_path = join(folder_path, image_name) + surf = pygame.image.load(full_path).convert_alpha() + frames.append(surf) + return frames + def import_folder_dict(*path): - frames = {} - for folder_path, sub_folders, image_names in walk(join(*path)): - for image_name in image_names: - full_path = join(folder_path, image_name) - surf = pygame.image.load(full_path).convert_alpha() - frames[image_name.split('.')[0]] = surf - return frames + frames = {} + for folder_path, sub_folders, image_names in walk(join(*path)): + for image_name in image_names: + full_path = join(folder_path, image_name) + surf = pygame.image.load(full_path).convert_alpha() + frames[image_name.split(".")[0]] = surf + return frames + def import_sub_folders(*path): - frames = {} - for _, sub_folders, __ in walk(join(*path)): - if sub_folders: - for sub_folder in sub_folders: - frames[sub_folder] = import_folder(*path, sub_folder) - return frames + frames = {} + for _, sub_folders, __ in walk(join(*path)): + if sub_folders: + for sub_folder in sub_folders: + frames[sub_folder] = import_folder(*path, sub_folder) + return frames + def import_tilemap(cols, rows, *path): - frames = {} - surf = import_image(*path) - cell_width, cell_height = surf.get_width() / cols, surf.get_height() / rows - for col in range(cols): - for row in range(rows): - cutout_rect = pygame.Rect(col * cell_width, row * cell_height,cell_width,cell_height) - cutout_surf = pygame.Surface((cell_width, cell_height)) - cutout_surf.fill('green') - cutout_surf.set_colorkey('green') - cutout_surf.blit(surf, (0,0), cutout_rect) - frames[(col, row)] = cutout_surf - return frames + frames = {} + surf = import_image(*path) + cell_width, cell_height = surf.get_width() / cols, surf.get_height() / rows + for col in range(cols): + for row in range(rows): + cutout_rect = pygame.Rect(col * cell_width, row * cell_height, cell_width, cell_height) + cutout_surf = pygame.Surface((cell_width, cell_height)) + cutout_surf.fill("green") + cutout_surf.set_colorkey("green") + cutout_surf.blit(surf, (0, 0), cutout_rect) + frames[(col, row)] = cutout_surf + return frames + def coast_importer(cols, rows, *path): - frame_dict = import_tilemap(cols, rows, *path) - new_dict = {} - terrains = ["sand"] - sides = { - 'topleft': (0,0), 'top': (1,0), 'topright': (2,0), - 'left': (0,1), 'right': (2,1), 'bottomleft': (0,2), - 'bottom': (1,2), 'bottomright': (2,2)} - for index, terrain in enumerate(terrains): - new_dict[terrain] = {} - for key, pos in sides.items(): - new_dict[terrain][key] = [frame_dict[(pos[0] + index * 3, pos[1] + row)] for row in range(0, rows, 3)] - return new_dict + frame_dict = import_tilemap(cols, rows, *path) + new_dict: dict[str, dict] = {} + terrains = ["sand"] + sides = { + "topleft": (0, 0), + "top": (1, 0), + "topright": (2, 0), + "left": (0, 1), + "right": (2, 1), + "bottomleft": (0, 2), + "bottom": (1, 2), + "bottomright": (2, 2), + } + for index, terrain in enumerate(terrains): + new_dict[terrain] = {} + for key, pos in sides.items(): + new_dict[terrain][key] = [frame_dict[(pos[0] + index * 3, pos[1] + row)] for row in range(0, rows, 3)] + return new_dict + def character_importer(cols, rows, *path): - frame_dict = import_tilemap(cols, rows, *path) - new_dict = {} - for row, direction in enumerate(("down", "left", "right", "up")): - new_dict[direction] = [frame_dict[(col, row)] for col in range(cols)] - new_dict[f"{direction}_idle"] = [frame_dict[(0, row)]] - return new_dict + frame_dict = import_tilemap(cols, rows, *path) + new_dict = {} + for row, direction in enumerate(("down", "left", "right", "up")): + new_dict[direction] = [frame_dict[(col, row)] for col in range(cols)] + new_dict[f"{direction}_idle"] = [frame_dict[(0, row)]] + return new_dict + def all_character_import(*path): - new_dict = {} - for _, _, image_names in walk(join(*path)): - for image in image_names: - image_name = image.split(".")[0] - new_dict[image_name] = character_importer(7, 4, *path, image_name) - return new_dict \ No newline at end of file + new_dict = {} + for _, _, image_names in walk(join(*path)): + for image in image_names: + image_name = image.split(".")[0] + new_dict[image_name] = character_importer(7, 4, *path, image_name) + return new_dict diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 01649f7..7fc7afb 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,11 +1,12 @@ -import sys import os +import sys # Add the project root to sys.path to allow imports to work when running tests directly with `python`. sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) import unittest -from src.GUI.inventory import Inventory, Chest, Quest + +from src.inventory import Chest, Inventory, Quest class TestInventory(unittest.TestCase): @@ -33,9 +34,7 @@ def test_remove_item_success(self): self.inventory.add_item("Potion", 3) result = self.inventory.remove_item("Potion", 2) self.assertEqual(self.inventory.items, {"Potion": 1}) - self.assertEqual( - result, "Successfully removed 2 Potion(s) from your inventory." - ) + self.assertEqual(result, "Successfully removed 2 Potion(s) from your inventory.") def test_remove_item_fail(self): """Test failing to remove an item not in inventory or insufficient quantity."""