commit 5cc2f19887fc2f438864713de24ff85ad8f51a2d Author: Matthias Guillitte Date: Sat Oct 10 13:38:11 2026 +0200 Simple plateformer game playing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8ac7c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +venv +__pycache__/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c9eb608 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Main.py", + "type": "debugpy", + "request": "launch", + "program": "${cwd}/main.py", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/game/blocks/__init__.py b/game/blocks/__init__.py new file mode 100644 index 0000000..acac64b --- /dev/null +++ b/game/blocks/__init__.py @@ -0,0 +1,9 @@ +from .stoneBlock import StoneBlock +from .spawnPointBlock import SpawnPointBlock +from .finishBlock import FinishBlock + +BLOCKS = [ + StoneBlock, + SpawnPointBlock, + FinishBlock, +] \ No newline at end of file diff --git a/game/blocks/block.py b/game/blocks/block.py new file mode 100644 index 0000000..b307eb0 --- /dev/null +++ b/game/blocks/block.py @@ -0,0 +1,31 @@ +from abc import abstractmethod + +from game.gameState import GameState + +class Block: + @property + @abstractmethod + def character(self): + pass + + @property + @abstractmethod + def type(self): + pass + + + @property + @abstractmethod + def solid(self): + pass + + def __init__(self): + pass + + @abstractmethod + def on_collision(self, gameState: GameState): + pass + + @abstractmethod + def to_string(self): + pass \ No newline at end of file diff --git a/game/blocks/finishBlock.py b/game/blocks/finishBlock.py new file mode 100644 index 0000000..5f97ce8 --- /dev/null +++ b/game/blocks/finishBlock.py @@ -0,0 +1,23 @@ + +from game.blocks.block import Block +from game.gameState import GameState + +class FinishBlock(Block): + @property + def character(self): + return 'E' + + @property + def type(self): + return 'finish' + + @property + def solid(self): + return False + + def on_collision(self, gameState: GameState): + gameState.set_stop_game(True) + gameState.set_game_won(True) + + def to_string(self): + return "🏁" \ No newline at end of file diff --git a/game/blocks/spawnPointBlock.py b/game/blocks/spawnPointBlock.py new file mode 100644 index 0000000..7d055c7 --- /dev/null +++ b/game/blocks/spawnPointBlock.py @@ -0,0 +1,22 @@ + +from game.blocks.block import Block +from game.gameState import GameState + +class SpawnPointBlock(Block): + @property + def character(self): + return 'S' + + @property + def type(self): + return 'spawn_point' + + @property + def solid(self): + return False + + def on_collision(self, gameState: GameState): + pass + + def to_string(self): + return "🚩" \ No newline at end of file diff --git a/game/blocks/stoneBlock.py b/game/blocks/stoneBlock.py new file mode 100644 index 0000000..83298a1 --- /dev/null +++ b/game/blocks/stoneBlock.py @@ -0,0 +1,23 @@ + +from game.blocks.block import Block +from game.gameState import GameState + +class StoneBlock(Block): + @property + def character(self): + return '#' + + @property + def type(self): + return 'stone' + + @property + def solid(self): + return True + + def on_collision(self, gameState: GameState): + # Stone blocks are solid, so the player cannot move through them. + pass + + def to_string(self): + return "⬛" \ No newline at end of file diff --git a/game/game.py b/game/game.py new file mode 100644 index 0000000..3aaad59 --- /dev/null +++ b/game/game.py @@ -0,0 +1,145 @@ +""" +Represents the main game class. +This class is responsible for managing the game's logic. +It offers to set a callback function that will be called when waiting for a player input. +It provides the gameState object to access the current state of the game. +It provides the Map object to access the map. +It provides some stopGame conditions to stop the game like the max number of moves or the player score. +It provides public methods to start, stop and reset the game. +It provides public methods to change the current map. +""" + +from game.blocks.block import Block +from game.gameState import GameState +from game.map import Map +from colorama import Style + + +class Game: + def __init__(self, map: Map, gameState: GameState): + self.map = map + self.gameState = gameState + self.waitingForInputCallback = None + + def set_waiting_for_input_callback(self, callback): + self.waitingForInputCallback = callback + + def set_position_update_callback(self, callback): + self.positionUpdateCallback = callback + + def _position_update(self): + if self.positionUpdateCallback: + self.positionUpdateCallback() + + def start_game(self): + self.gameState.set_stop_game(False) + + # Spawn the player at the spawn point + spawn_point_position = self.map.get_first_block_type_position('spawn_point') + if spawn_point_position is None: + raise ValueError("No spawn point found in the map.") + self.gameState.set_player_position(spawn_point_position) + + while not self.gameState.stopGame: + self._position_update() # Update the screen for example + + if self.gameState.playerVelocity == -1 and self.waitingForInputCallback: + self.gameState.set_player_velocity(self.waitingForInputCallback()) + self.gameState.add_move_count() + + # Try to move the player according to his velocity + block_at_velocity = self._get_block_at_velocity() + if not block_at_velocity or not block_at_velocity.solid: + velocity_position = self.get_position_at_velocity() # TODO : optimize + if velocity_position: + self.gameState.set_player_position(velocity_position) + if block_at_velocity: + block_at_velocity.on_collision(self.gameState) # Do something when the player is on the block, like add score or stop the game + # Check if the player is on a solid block + player_position = self.gameState.get_player_position() + block_at_player_position = self.map.get_block_at(player_position[0], player_position[1] + 1) + if block_at_player_position and block_at_player_position.solid: + self.gameState.set_player_velocity(-1) # Stop the player from falling + + self._update_player_velocity() + + + def _update_player_velocity(self): + current_velocity = self.gameState.playerVelocity + if current_velocity == -1: # If no velocity, then the player is not moving, so we don't need to update the velocity + return + + if current_velocity == "down": # Falling straight down → Still falling except if the player is on a block + if not self._get_block_at_velocity() == None and self._get_block_at_velocity().solid: # If block at where the player is going is solid, then the player will stop falling + self.gameState.set_player_velocity(-1) + return + + if current_velocity is "up": # Going up → Going down + self.gameState.set_player_velocity("down") + return + + if current_velocity in ["left_down", "right_down"]: + self.gameState.set_player_velocity("down") + return + + if current_velocity in ["left", "right"]: + self.gameState.set_player_velocity(current_velocity + "_down") + block_at_velocity = self._get_block_at_velocity() + if not block_at_velocity == None and block_at_velocity.solid: # If block at where the player is going is solid, then the player will fall down + self.gameState.set_player_velocity("down") + return + + if current_velocity in ["left_up", "right_up"]: + self.gameState.set_player_velocity(current_velocity.replace("_up", "_down")) + block_at_velocity = self._get_block_at_velocity() + if not block_at_velocity == None and block_at_velocity.solid: # If block at where the player is going is solid, then the player will fall down + self.gameState.set_player_velocity("down") + return + + def get_position_at_velocity(self) -> tuple | None: + player_position = self.gameState.get_player_position() + current_velocity = self.gameState.playerVelocity + + if current_velocity == -1: + return None + + if current_velocity == "down": + return (player_position[0], player_position[1] + 1) + + if current_velocity == "left_down": + return (player_position[0] - 1, player_position[1] + 1) + + if current_velocity == "right_down": + return (player_position[0] + 1, player_position[1] + 1) + + if current_velocity == "left": + return (player_position[0] - 1, player_position[1]) + + if current_velocity == "right": + return (player_position[0] + 1, player_position[1]) + + if current_velocity == "left_up": + return (player_position[0] - 1, player_position[1] - 1) + + if current_velocity == "right_up": + return (player_position[0] + 1, player_position[1] - 1) + + return None + + def _get_block_at_velocity(self) -> Block | None: + position_at_velocity = self.get_position_at_velocity() + if position_at_velocity is None: + return None + return self.map.get_block_at(position_at_velocity[0], position_at_velocity[1]) + + def stop_game(self): + self.gameState.set_stop_game(True) + + def to_string(self): + """ + Returns a string representation of the game state and map with player position. + """ + player_position = self.gameState.get_player_position() + map_string_dict = self.map.to_string_dict() + map_string_dict[player_position[1]][player_position[0]] = f'{Style.BRIGHT}🦧' + return '\n'.join([''.join(character + Style.RESET_ALL for character in row) for row in map_string_dict]) \ No newline at end of file diff --git a/game/gameState.py b/game/gameState.py new file mode 100644 index 0000000..60c4091 --- /dev/null +++ b/game/gameState.py @@ -0,0 +1,46 @@ +""" +Represents the current state of the game. +Contains : +- Player score +- stopGame flag +- Player position +""" +class GameState: + PLAYER_MOVEMENTS = ["left_down", "left", "left_up", "up", "right_up", "right", "right_down"] + PLAYER_VELOCITIES = [*PLAYER_MOVEMENTS, "down"] + + def __init__(self): + self.score = 0 + self.playerPosition = (0, 0) + self.stopGame = False + self.movesCount = 0 + self.playerVelocity = -1 # -1 means no velocity + self.gameWon = False + + def add_score(self, points): + self.score += points + + def set_player_position(self, x, y): + self.playerPosition = (x, y) + + def set_player_position(self, position: tuple): + self.playerPosition = position + + def get_player_position(self): + return self.playerPosition + + def set_player_velocity(self, velocity): + """Set the player velocity, meaning where the player is currently going + """ + if velocity not in self.PLAYER_VELOCITIES and velocity != -1: + raise ValueError(f"Invalid player velocity: {velocity}. Must be one of {self.PLAYER_VELOCITIES} or -1.") + self.playerVelocity = velocity + + def add_move_count(self, nb=1): + self.movesCount += nb + + def set_stop_game(self, stop): + self.stopGame = stop + + def set_game_won(self, won): + self.gameWon = won \ No newline at end of file diff --git a/game/handlers/__init__.py b/game/handlers/__init__.py new file mode 100644 index 0000000..8b5a4c5 --- /dev/null +++ b/game/handlers/__init__.py @@ -0,0 +1,5 @@ +from .userPlayerGameHandler import UserPlayerGameHandler + +GAME_HANDLERS= { + "user player": UserPlayerGameHandler, +} \ No newline at end of file diff --git a/game/handlers/gameHandler.py b/game/handlers/gameHandler.py new file mode 100644 index 0000000..622fd54 --- /dev/null +++ b/game/handlers/gameHandler.py @@ -0,0 +1,17 @@ +from abc import abstractmethod + +from game.game import Game + +class GameHandler: + def __init__(self, game: Game): + self.game = game + self.game.set_waiting_for_input_callback(self.on_input_request) + self.game.set_position_update_callback(self.on_position_update) + + @abstractmethod + def on_position_update(self) -> None: + pass + + @abstractmethod + def on_input_request(self) -> str: + pass \ No newline at end of file diff --git a/game/handlers/userPlayerGameHandler.py b/game/handlers/userPlayerGameHandler.py new file mode 100644 index 0000000..9dd2d34 --- /dev/null +++ b/game/handlers/userPlayerGameHandler.py @@ -0,0 +1,24 @@ + +from time import sleep +from game.game import Game +from game.handlers.gameHandler import GameHandler + + +class UserPlayerGameHandler(GameHandler): + KEYBOARD_INPUTS = ["w", "q", "a", "z", "e", "d", "c"] + + def __init__(self, game: Game): + super().__init__(game) + + def on_position_update(self) -> None: + """ + Refresh the screen + """ + print("\033[2J\033[H" + self.game.to_string()) + sleep(0.1) + + def on_input_request(self) -> str: + """ + Listen for keyboard input, and map it to a gameState PLAYER_MOVEMENTS + """ + return self.game.gameState.PLAYER_MOVEMENTS[self.KEYBOARD_INPUTS.index(input(f"Enter a movement ({', '.join(self.KEYBOARD_INPUTS)}): "))] \ No newline at end of file diff --git a/game/map.py b/game/map.py new file mode 100644 index 0000000..15273d9 --- /dev/null +++ b/game/map.py @@ -0,0 +1,54 @@ +""" +Represent a map in the game. +Contains the map data : + - Map name + - Map width and height + - Map difficulty + - The map layout (2D array of blocks) +""" + +from game.blocks.block import Block + + +class Map: + def __init__(self, map_data: dict): + self.name = map_data.get('name', 'Unknown') + self.width = int(map_data.get('width', 0)) + self.height = int(map_data.get('height', 0)) + self.difficulty = float(map_data.get('difficulty', 0.0)) + self.layout = map_data.get('layout', []) + + def get_block_at(self, x, y) -> Block | None: + if 0 <= x < self.width and 0 <= y < self.height: + return self.layout[y][x] + return None + + def get_first_block_type_position(self, block_type): + for y in range(self.height): + for x in range(self.width): + block = self.get_block_at(x, y) + if block and block.type == block_type: + return (x, y) + return None + + def get_block_type_at_position(self, position): + x, y = position + block = self.get_block_at(x, y) + if block: + return block.type + return None + + def to_string_dict(self): + """ + Returns a string matrix representation of the map layout. + """ + string_array = [] + for row in self.layout: + string_array_row = [] + for block in row: + if block is None: + string_array_row.append(" ") + else: + string_array_row.append(block.to_string()) + string_array.append(string_array_row) + return string_array \ No newline at end of file diff --git a/game/mapReader.py b/game/mapReader.py new file mode 100644 index 0000000..4572bed --- /dev/null +++ b/game/mapReader.py @@ -0,0 +1,60 @@ +""" +Reads map files and returns a Map object. +It provides a method to read a map file by it's name and return the corresponding Map object. +It provides a method to get all of the map names in the maps directory. +""" + +import os + +from game.map import Map +import tomllib + +class MapReader: + DIRECTORY = "maps/" + + def __init__(self): + pass + + def get_map_names(self): + return [f[:-5] for f in os.listdir(self.DIRECTORY) if f.endswith('.toml')] + + def read_map(self, map_name): + import configparser + + map_path = os.path.join(self.DIRECTORY, map_name + ".toml") + if not os.path.exists(map_path): + raise FileNotFoundError(f"Map file {map_name} not found in {self.DIRECTORY}") + + with open(map_path, 'rb') as f: + config = tomllib.load(f) + + map_data = { + 'name': map_name, + 'width': config.get('width', 0), + 'height': config.get('height', 0), + 'difficulty': config.get('difficulty', 0.0), + 'layout': self._parse_layout(config.get('layout', '')) + } + + return Map(map_data) + + def _get_block_characters_mapping(self): + # Returns a dictionary mapping block characters to their corresponding block classes. + from game.blocks import BLOCKS + return {block().character: block for block in BLOCKS} + + def _parse_layout(self, mapString): + # Takes the blocks and translates their characters into the corresponding block objects. + block_characters_mapping = self._get_block_characters_mapping() + layout = [] + for line in mapString.strip().splitlines(): + row = [] + for char in line: + block_class = block_characters_mapping.get(char) + if block_class: + row.append(block_class()) + else: + row.append(None) + layout.append(row) + return layout + \ No newline at end of file diff --git a/game/mapSet.py b/game/mapSet.py new file mode 100644 index 0000000..5f80664 --- /dev/null +++ b/game/mapSet.py @@ -0,0 +1,26 @@ +""" +Represents a set of maps in the game. At initialization, it loads the desired maps. +It provides a method to get a number of maps with the difficulty level as close as possible to the desired difficulty level. +""" + +from game.mapReader import MapReader + +class MapSet: + def __init__(self, mapReader: MapReader, mapNames: list): + self.mapReader = mapReader + self.maps = [self.mapReader.read_map(name) for name in mapNames] + + def get_map(self, index: int): + if index < 0 or index >= len(self.maps): + raise IndexError("Map index out of range.") + return self.maps[index] + + def get_maps_by_difficulty(self, desired_difficulty: int, num_maps: int): + """ + Returns a list of maps with difficulty levels as close as possible to the desired difficulty level. + If there are not enough maps, it returns all available maps. + """ + if len(self.maps) == 0: + raise ValueError("No maps available in the MapSet.") + sorted_maps = sorted(self.maps, key=lambda m: abs(m.difficulty - desired_difficulty)) + return sorted_maps[:num_maps] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..abfdd42 --- /dev/null +++ b/main.py @@ -0,0 +1,17 @@ +from game.game import Game +from game.gameState import GameState +from game.handlers import GAME_HANDLERS +from game.map import Map +from game.mapReader import MapReader +from game.mapSet import MapSet + +if __name__ == "__main__": + mapReader = MapReader() + mapSet = MapSet(mapReader, mapReader.get_map_names()) + map = mapSet.get_maps_by_difficulty(0, 1)[0] + gameState = GameState() + game = Game(map, gameState) + gameHandler = GAME_HANDLERS["user player"](game) + game.start_game() + + # run_game() diff --git a/maps/default.toml b/maps/default.toml new file mode 100644 index 0000000..565f047 --- /dev/null +++ b/maps/default.toml @@ -0,0 +1,11 @@ +width = 12 +height = 6 +difficulty = 10 +layout = """\ +############ +# E# +# ° °## +# ° # ### +#S # ##^### +############ +""" \ No newline at end of file