diff --git a/1.dat b/1.dat new file mode 100644 index 0000000..2c97f61 Binary files /dev/null and b/1.dat differ diff --git a/2.dat b/2.dat new file mode 100644 index 0000000..301105e Binary files /dev/null and b/2.dat differ diff --git a/dev_edit.py b/dev_edit.py new file mode 100644 index 0000000..fa209c0 --- /dev/null +++ b/dev_edit.py @@ -0,0 +1,274 @@ +""" + LEVEL EDITOR(prototype) + The file that allows to create and edit levels + THIS PROGRAM IS DEPRECATED, USE LVL_EDIT.PY TO EDIT LEVELS +""" +import pyxel +import json +import shutil + +CELL_SIZE = 20 +GRID_X = 11 +GRID_Y = 6 + +WIN_X = CELL_SIZE*GRID_X +WIN_Y = CELL_SIZE*GRID_Y + + +def clamp(n: int, a: int, b: int): + """ + :param n: The number to be clamped + :param a: The minimum limit + :param b: The maximum limit + :return: The clamped number + Returns the provided number n clamped between a and b + """ + if n < a: + return a + if n > b: + return b + return n + + +def get_file_content(lvl: int) -> list: + """ + :param lvl: (int) The id of the file to read + :return: (list) The level content of the file + Reads a level file content and returns its data + """ + file = open(f"lvl/{lvl}.json", "r", encoding="utf-8") + decoder = json.JSONDecoder() + content = decoder.decode(file.read()) + file.close() + return content + + +def can_load(lvl: int) -> bool: + """ + :param lvl: (int) The id of the file to check for + :return: Returns if the level file exists and can be loaded + """ + try: + file = get_file_content(lvl) + return len(file) == GRID_Y + except FileNotFoundError: + return False + + +class LevelManager: + def __init__(self): + self.level = 0 # the id of the currently loaded stage + self.cache = [] # the current stage state is stored there + + self.tileTypes = { # helps with tile identifiers + "empty": 0, + "wall": 1, + "player": 3, + "exit": 2, + "switch_blue": 4, + "s_wall_blue": 5 + } + + def load(self, lvl: int): + """ + :param lvl: (int) the level id + + Reloads the game using the specified level + """ + self.level = lvl + self.cache = get_file_content(lvl) + + def save(self, lvl: int): + """ + :param lvl: (int) the level id + + Saves the cache under the specified level id + """ + file = open(f"lvl/{lvl}.json", "w", encoding="utf-8") + encoder = json.JSONEncoder() + file.write(encoder.encode(self.cache)) + file.close() + + def update_tile(self, position: tuple[int, int], val: int): + """ + :param position: (tuple) The position of the tile to modify + :param val: (int) The new value of the tile + + Updates the specified tile from the cache to the specified value + """ + self.cache[position[1]][position[0]] = val + + def get_tile(self, position: tuple[int, int]) -> int: + """ + :param position: (tuple) the position of the requested tile + :return: (int) the value of the tile + Fetches the current value of the specified tile or -1 if out of range + """ + if not self.is_in_range(position): + return -1 + return self.cache[position[1]][position[0]] + + def is_in_range(self, position: tuple[int, int]) -> bool: + """ + :param position: (tuple) The position that will be checked + :return: (bool) indicates whether the said position is in map bounds + + Checks if the given position is within the map range(a level should have been loaded first) + """ + if len(self.cache) == 0: + return False + if position[0] < 0 or position[1] < 0: + return False + if position[1] > GRID_Y-1: + return False + if position[0] > GRID_X-1: + return False + return True + + def move_tile(self, tile_a, tile_b): + """ + :param tile_a: (tuple) The position of the tile to be moved + :param tile_b: (tuple) The position of the target tile + Moves the content of tile_a to tile_b(overwrites tile_b and sets tile_a to 0) + This is meant for player movement + """ + if tile_a != tile_b: + content = self.get_tile(tile_a) + self.update_tile(tile_b, content) + self.update_tile(tile_a, self.tileTypes['empty']) + + def find_object(self, elt: int) -> tuple[int, int]: + """ + :param elt: (int) The type of element to find on the map(use self.tileTypes) + :return: (tuple) The position of the tile(or (-1, -1) if not found) + Finds the first instance of the provided element on the board, and returns its position + """ + for y in range(GRID_Y): + for x in range(GRID_X): + if self.cache[y][x] == elt: + return x, y + return -1, -1 + + def find_player(self): # finds the player in the stage grid and returns its coordinates + return self.find_object(self.tileTypes['player']) + + +def input_cmd(): + cmd_txt = input("cmd => ").split(" ") + if cmd_txt[0] == "load" and len(cmd_txt) > 1 and cmd_txt[1].isnumeric() and can_load(int(cmd_txt[1])): + print(f"Now editing level {cmd_txt[1]}") + runner.level.load(int(cmd_txt[1])) + elif cmd_txt[0] == "create" and len(cmd_txt) > 1 and cmd_txt[1].isnumeric(): + print(f"Now editing level {cmd_txt[1]}") + shutil.copyfile("lvl_template.json", f'lvl/{cmd_txt[1]}.json') + runner.level.load(int(cmd_txt[1])) + elif cmd_txt[0] == "save": + runner.level.save(runner.level.level) + print(f"Saved successfully") + else: + print("Invalid Command") + + +class Editor: + def __init__(self): + self.level = LevelManager() # An instance that manages level data and navigating + self.isInit = False # Safety attribute that prevents init from running twice + self.cursor = (0, 0) # Describes the mouse position(snapped to grid) + self.toolToggle = 0 + + self.toolToggleRotation = [ + self.level.tileTypes['wall'], + self.level.tileTypes['exit'], + self.level.tileTypes['switch_blue'], + self.level.tileTypes['s_wall_blue'] + ] + + self.element_display = [ + "empty", + "wall", + "exit", + "player", + "blue switch", + "blue switch wall" + ] + + def on_click(self): # toggles the presence of an element at cursor position + tile = self.level.get_tile(self.cursor) + if tile != self.toolToggleRotation[self.toolToggle] and tile != self.level.tileTypes['player']: + self.level.update_tile(self.cursor, self.toolToggleRotation[self.toolToggle]) + elif tile != self.level.tileTypes['player']: + self.level.update_tile(self.cursor, self.level.tileTypes['empty']) + + def move_player(self): # moves the player to the cursor(overwrites said tile) + if self.cursor[0] >= 0 and self.cursor[1] >= 0: + plr_pos = self.level.find_player() + self.level.move_tile(plr_pos, self.cursor) + + def tool_rotation(self): # Changes the element that will be placed on click + next_index = self.toolToggle + 1 + if next_index >= len(self.toolToggleRotation): + self.toolToggle = 0 + else: + self.toolToggle = next_index + new_element = self.toolToggleRotation[self.toolToggle] + print(f"Now placing {self.element_display[new_element]}") + + # manages controls and tools + def upd(self): + mx, my = pyxel.mouse_x, pyxel.mouse_y + self.cursor = (mx//CELL_SIZE, my//CELL_SIZE) + if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT): + self.on_click() + elif pyxel.btnp(pyxel.KEY_E): + self.move_player() + elif pyxel.btnp(pyxel.KEY_A): + self.tool_rotation() + elif pyxel.btnp(pyxel.KEY_C): + input_cmd() + + # utility methods that draws the cursor to the mouse position + def draw_cursor(self): + x, y = self.cursor + center_x = x*CELL_SIZE+CELL_SIZE//2 + center_y = y*CELL_SIZE+CELL_SIZE//2 + pyxel.circb(center_x, center_y, 5, 4) + + # self-explanatory, manages what's seen on screen + def display(self): + pyxel.cls(0) + for y in range(GRID_Y): + for x in range(GRID_X): + tile = self.level.get_tile((x, y)) + if tile == self.level.tileTypes['wall']: + pyxel.rect(x*CELL_SIZE, y*CELL_SIZE, CELL_SIZE, CELL_SIZE, 7) + elif tile == self.level.tileTypes['exit']: + pyxel.rect(x*CELL_SIZE, y*CELL_SIZE, CELL_SIZE, CELL_SIZE, 11) + elif tile == self.level.tileTypes['player']: + pyxel.circ(x*CELL_SIZE+CELL_SIZE//2, y*CELL_SIZE+CELL_SIZE//2, CELL_SIZE//2-1, 8) + elif tile == self.level.tileTypes['switch_blue']: + pyxel.circ(x*CELL_SIZE+CELL_SIZE//2, y*CELL_SIZE+CELL_SIZE//2, CELL_SIZE//3-1, 12) + elif tile == self.level.tileTypes['s_wall_blue']: + pyxel.rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE, 12) + self.draw_cursor() + + def init(self, lvl: int): # opens the editor window and loads the specified stage + if not self.isInit: + self.isInit = True + self.level.load(lvl) + pyxel.init(WIN_X, WIN_Y, title="ice_walker editor") + pyxel.mouse(True) + pyxel.run(self.upd, self.display) + + +def start_editor(): + runner.init(int(lvl_id)) + + +if __name__ == "__main__": + lvl_id = input("Please enter the id of the level to load: ") + if lvl_id.isnumeric(): + print(f"Now editing level {lvl_id}") + runner = Editor() + start_editor() + else: + print("Invalid level id provided, please rerun and try again") diff --git a/json_to_bin.py b/json_to_bin.py new file mode 100644 index 0000000..e72502d --- /dev/null +++ b/json_to_bin.py @@ -0,0 +1,21 @@ +""" + Converts json level data files into binary level data +""" +import json +from os import listdir +from os.path import isfile, join + +LVL_DIR = "lvl/" +BIN_DIR = "lvl_bin/" + +if __name__ == "__main__": + files = [f for f in listdir(LVL_DIR) if isfile(join(LVL_DIR, f))] + decoder = json.JSONDecoder() + for v in files: + file = open(join(LVL_DIR, v), "r", encoding="utf-8") + content = decoder.decode(file.read()) + newfile = open(join(BIN_DIR, v.replace(".json", ".dat")), "wb") + bin_content = b'' + for line in content: + bin_content += bytes(line+[255]) + newfile.write(bin_content) diff --git a/lvl_edit.py b/lvl_edit.py new file mode 100644 index 0000000..ead6134 --- /dev/null +++ b/lvl_edit.py @@ -0,0 +1,169 @@ +""" + ICE WALKER LEVEL EDITOR + Runs a level editor which can be used to create and edit levels +""" + +import objects +import json +import pyxel + +CELL_SIZE = 8 +GRID_X = 11 +GRID_Y = 6 + +WIN_X = CELL_SIZE*GRID_X +WIN_Y = CELL_SIZE*GRID_Y + + +def load_file(lvl: int) -> list: + """ + :param lvl: (int) The id of the level to load + :return: (list) The loaded level from the file + Reads the corresponding level file and returns its loaded content + """ + file = open(f"lvl/{lvl}.json", "r", encoding="utf-8") + decoder = json.JSONDecoder() + return decoder.decode(file.read()) + + +def can_load(lvl: int) -> bool: + """ + :param lvl: (int) The id of the file to check for + :return: (bool) Returns if the level file exists and can be loaded + Tests if a level can be loaded, returns the result as a boolean + """ + try: + file = load_file(lvl) + return len(file) == GRID_Y + except FileNotFoundError: + return False + + +class LevelManager: + """ + This class is responsible for handling game data + """ + def __init__(self): + self.current_level = 0 + self.cache = [] + + def load_level(self, lvl: int): + """ + :param lvl: (int) The id of the level to load + Loads the specified level and stores it into the cache + """ + if can_load(lvl): + self.cache = load_file(lvl) + self.current_level = lvl + print(f"editing level {lvl}") + + def load_collection_from_cache(self, collection: objects.Collection) -> objects.Player | None: + """ + :param collection: (Collection) The collection to load the objects into + :return: The player object created by this method + Loads the level elements using the loaded cache, returns the new player instance + """ + new_player = None + for y in range(len(self.cache)): + for x in range(len(self.cache[y])): + element_id = self.cache[y][x] + elt = objects.get_type_from_id(element_id) + if elt: + new = elt() + new.position = objects.V2(x, y) + collection.add(new) + if element_id == 3: + new_player = new + return new_player + + +class Game: + """ + This class manages the game runtime + """ + def __init__(self): + self.level = None # An instance of the LevelManager class + self.main_collection = None # An instance of Collection, usually the main one + self.editor_collection = None # An instance of Collection used by the editor and ignored while saving + self.player = None # The current instance of the Player object + self.cursor = objects.Cursor() + + self._drag = None # The current object being dragged in the editor + self._dragging = False # Determines is the mouse is currently in the dragging state + + def _upd(self): + if self.player: + change_level = 0 + + # Update cursor position + mx, my = pyxel.mouse_x, pyxel.mouse_y + self.cursor.position = objects.clamp_in_range(objects.V2(mx//CELL_SIZE, my//CELL_SIZE)) + + if pyxel.btnp(pyxel.KEY_LEFT): # Level switch controls + change_level = -1 + elif pyxel.btnp(pyxel.KEY_RIGHT): + change_level = 1 + elif pyxel.btnp(pyxel.KEY_DELETE): # Object management controls + obj = self.main_collection.get_at_position(self.cursor.position) + if obj and obj.can_be_deleted: + self.main_collection.remove(obj, True) + + if change_level != 0: # Clear cache and load another level + self._drag = None + self._dragging = False + self.level.load_level(self.level.current_level + change_level) + self.main_collection.clear_all() + new_player = self.level.load_collection_from_cache(self.main_collection) + self.player = new_player + self.main_collection.add(self.editor_collection) + return # Not handle the rest because not necessary + + if pyxel.btn(pyxel.MOUSE_BUTTON_LEFT): + if not self._dragging: + self._dragging = True + self._drag = self.main_collection.get_at_position(self.cursor.position) + elif self._drag: + self._drag.position = self.cursor.position + else: + self._dragging = False + self._drag = None + + def _display(self): + pyxel.cls(0) + for v in self.main_collection.get_objects(): # Render all active objects + if isinstance(v, objects.MapObject): + v.draw() + elif isinstance(v, objects.Collection) and v.visible: # Render embedded collections, if visible + for embed_v in v.get_objects(): + embed_v.draw() + + # Draw cursor + pyxel.rect(pyxel.mouse_x, pyxel.mouse_y, 1, 1, 5) + + def run(self, lvl_m: LevelManager, main_c: objects.Collection, editor_c: objects.Collection): + """ + :param lvl_m: (LevelManager) The instance of LevelManager to assign with the game instance + :param main_c: (Collection) The primary collection containing the entirety of the game + :param editor_c: (Collection) The editor's collection + Initializes and runs the editor + """ + self.level = lvl_m + self.main_collection = main_c + self.editor_collection = editor_c + editor_c.add(self.cursor) + main_c.add(editor_c) + pyxel.init(WIN_X, WIN_Y, title="ice_walker") + pyxel.run(self._upd, self._display) + + +if __name__ == "__main__": + main_collection = objects.Collection() + editor_collection = objects.Collection() + + level = LevelManager() # Load the first level of the game + level.load_level(1) + player = level.load_collection_from_cache(main_collection) + + game = Game() # Initializes the game engine + game.player = player + game.run(level, main_collection, editor_collection) diff --git a/lvl_template.json b/lvl_template.json new file mode 100644 index 0000000..f3d5e71 --- /dev/null +++ b/lvl_template.json @@ -0,0 +1,8 @@ +[ + [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..83ce349 --- /dev/null +++ b/main.py @@ -0,0 +1,163 @@ +""" + ICE WALKER GAME RUNNER + The main file that runs the game itself +""" + +import objects +import json +import pyxel +import pathlib + +CELL_SIZE = 8 +GRID_X = 11 +GRID_Y = 6 + +WIN_X = CELL_SIZE*GRID_X +WIN_Y = CELL_SIZE*GRID_Y + + +def load_file_json(lvl: int) -> list: + """ + :param lvl: (int) The id of the level to load + :return: (list) The loaded level from the file + Reads the corresponding level file and returns its loaded content + """ + file = open(f"lvl/{lvl}.json", "r", encoding="utf-8") + decoder = json.JSONDecoder() + return decoder.decode(file.read()) + + +def load_file(lvl: int) -> list: + """ + :param lvl: (int) The id of the level to load + :return: (list) The loaded level from the file + Reads the corresponding level file and returns its loaded content + """ + file = open(f"lvl_bin/{lvl}.dat", "rb") + content = file.read() + res = [] + mem = [] + for v in content: + if int(v) == 255: + res.append(mem) + mem = [] + else: + mem.append(int(v)) + return res + + +def can_load(lvl: int) -> bool: + """ + :param lvl: (int) The id of the file to check for + :return: (bool) Returns if the level file exists and can be loaded + Tests if a level can be loaded, returns the result as a boolean + """ + path = pathlib.Path(f"lvl_bin/{lvl}.dat") + return path.is_file() + + +class LevelManager: + """ + This class is responsible for handling game data + """ + def __init__(self): + self.current_level = 0 + self.cache = [] + + def load_level(self, lvl: int): + """ + :param lvl: (int) The id of the level to load + Loads the specified level and stores it into the cache + """ + if can_load(lvl): + self.cache = load_file(lvl) + self.current_level = lvl + + def load_collection_from_cache(self, collection: objects.Collection) -> objects.Player | None: + """ + :param collection: (Collection) The collection to load the objects into + :return: The player object created by this method + Loads the level elements using the loaded cache, returns the new player instance + """ + new_player = None + for y in range(len(self.cache)): + for x in range(len(self.cache[y])): + element_id = self.cache[y][x] + elt = objects.get_type_from_id(element_id) + if elt: + new = elt() + new.position = objects.V2(x, y) + collection.add(new) + if element_id == 3: + new_player = new + return new_player + + +class Game: + """ + This class manages the game runtime + """ + def __init__(self): + self.level = None # An instance of the LevelManager class + self.main_collection = None # An instance of Collection, usually the main one + self.player = None # The current instance of the Player object + + def _upd(self): + if self.player: + if pyxel.btnp(pyxel.KEY_R): # Reset the current level + self.main_collection.clear_all() + self.player = self.level.load_collection_from_cache(self.main_collection) + + direction = objects.V2() + if pyxel.btnp(pyxel.KEY_Z): # Determine move direction + direction.Y = -1 + elif pyxel.btnp(pyxel.KEY_S): + direction.Y = 1 + elif pyxel.btnp(pyxel.KEY_Q): + direction.X = -1 + elif pyxel.btnp(pyxel.KEY_D): + direction.X = 1 + + if not direction.is_zero(): # Handles the actual movement and collisions + new_pos, colliders = self.main_collection.ray_cast(self.player.position, direction, self.player) + self.player.position = new_pos + for v in colliders: + v.touched(self.player) + + if self.main_collection.exits == 0: # Handles when the level was cleared + self.level.load_level(self.level.current_level + 1) + self.main_collection.clear_all() + new_player = self.level.load_collection_from_cache(self.main_collection) + self.player = new_player + + def _display(self): + pyxel.cls(0) + for v in self.main_collection.get_objects(): # Render all active objects + if isinstance(v, objects.MapObject): + v.draw() + elif isinstance(v, objects.Collection) and v.visible: # Render embedded collections, if visible + for embed_v in v.get_objects(): + embed_v.draw() + + def run(self, lvl_m: LevelManager, main_c: objects.Collection): + """ + :param lvl_m: (LevelManager) The instance of LevelManager to assign with the game instance + :param main_c: (Collection) The primary collection containing the entirety of the game + Initializes and runs the game + """ + self.level = lvl_m + self.main_collection = main_c + pyxel.init(WIN_X, WIN_Y, title="ice_walker") + pyxel.run(self._upd, self._display) + + +if __name__ == "__main__": + main_collection = objects.Collection() + + level = LevelManager() # Load the first level of the game + level.load_level(1) + player = level.load_collection_from_cache(main_collection) + + game = Game() # Initializes the game engine + game.player = player + game.run(level, main_collection) diff --git a/objects.py b/objects.py new file mode 100644 index 0000000..4f1b5ad --- /dev/null +++ b/objects.py @@ -0,0 +1,378 @@ +""" + ICE WALKER OBJECTS LIBRARY + Defines all the objets and their behavior +""" + +import pyxel + +CELL_SIZE = 8 +GRID_X = 11 +GRID_Y = 6 + +WIN_X = CELL_SIZE*GRID_X +WIN_Y = CELL_SIZE*GRID_Y + +# Superclass and other classes definitions + + +class V2: + """ + This object represents a grid point + """ + def __init__(self, x=0, y=0): + self.X = x # The horizontal position of the object + self.Y = y # The vertical position of the object + + def __eq__(self, other): # equality operator handler + if isinstance(other, V2): + return self.X == other.X and self.Y == other.Y + return NotImplemented + + def __add__(self, other): # add operator handler + if isinstance(other, V2): + return V2(self.X+other.X, self.Y+other.Y) + return NotImplemented + + def is_zero(self) -> bool: # returns True if object's X and Y are 0 + return self.X == 0 and self.Y == 0 + + +# static functions + +def is_in_range(position: V2) -> bool: + """ + :param position: (V2) The grid position to check for + :return: (bool) False is the given position is out of range + Returns whether the given position is within the map range + """ + if position.X < 0 or position.X > GRID_X-1: # Check horizontal range + return False + if position.Y < 0 or position.Y > GRID_Y-1: # Check vertical range + return False + return True + + +def clamp_in_range(position: V2) -> V2: + """ + :param position: (V2) The grid position to check for + :return: (V2) The position to process + Returns a clamped version of the given position, so it is within the map range + """ + x, y = position.X, position.Y + if position.X < 0: # Check horizontal range + x = 0 + elif position.X > GRID_X-1: + x = GRID_X-1 + if position.Y < 0: # Check vertical range + y = 0 + elif position.Y > GRID_Y-1: + y = GRID_Y-1 + return V2(x, y) + + +class MapObject: + """ + The base class for objects within the game. + unlike Collections, MapObjects can't hold any children. + Should not be constructed directly. + """ + def __init__(self): + self.collisions = False # Determines if the object can collide with the player + self.ID = 0 # The object data reference + self.display_name = "empty" # How the object is called in the editor + self.runID = "empty" # How the object type will be referenced as internally + self.position = V2() # The grid position of the object + self.can_be_placed = True # Determines if the object can be placed in the editor + self.can_be_deleted = True # Determines if the object can be deleted in the editor + self.collection = None # The collection the object belongs to + + def draw(self): # This method will be called every frame by the game to render the object + pass + + def removed(self): # This method is called right before the objet is deleted + pass + + def touched(self, collider: 'MapObject'): # Handles collision reactions + pass + + +class Collection: + """ + Collections objects are responsible for handling MapObjects in a running game. + Collections can be embedded to each other, making a folder logic + """ + def __init__(self): + self._instances = [] # The MapObjects and Collections this Collection holds. + self.exits = 0 # The number of exits currently present on the map, should be read only. + self.visible = True # If embedded, setting this to False will skip the render of the objects it contains. + + def find_by_type(self, type_object) -> MapObject | None: + """ + :param type_object: The type of the object to check for + :return: An object of the given type_object type + Returns the first object of the type_object type found in the running game + """ + for v in self._instances: + if isinstance(v, type_object): + return v + return None + + def _locate(self, object_to_locate) -> int: + """ + :param object_to_locate: (MapObject | Collection) The object to look for in the running game + :return: (int) The index of the object in the data table + Finds the reference to object_to_locate in the DataModel instances table, returns -1 if not found + """ + for i in range(len(self._instances)): + if self._instances[i] == object_to_locate: + return i + return -1 + + def remove(self, object_to_remove: MapObject, silent: bool = False): + """ + :param object_to_remove: (MapObject) The object to remove from the running game + :param silent: (bool) If true, does not call "MapObject.removed" + Finds and removes the given object from the running game after calling "MapObject.removed" if not silent + """ + inst_index = self._locate(object_to_remove) + if inst_index >= 0: + if isinstance(object_to_remove, Collection): + # We remove embedded collections directly because they don't have any data we need to remove first + self._instances.remove(object_to_remove) + return + if not silent: + object_to_remove.removed() + object_to_remove.collection = None + if object_to_remove.ID == 2: # Count one exit less if what we're removing is an Exit object(which has ID 2) + self.exits -= 1 + + self._instances.remove(object_to_remove) + + def add(self, object_to_add): + """ + :param object_to_add: (MapObject) The object to add to the collection + Adds the given object to the collection + """ + if isinstance(object_to_add, MapObject) and object_to_add.collection is None: + object_to_add.collection = self + if object_to_add.ID == 2: + self.exits += 1 + self._instances.append(object_to_add) + elif isinstance(object_to_add, Collection): # Collections can be embedded into another one + self._instances.append(object_to_add) + else: + print("Cannot add an object to a collection when it already belongs to one") + + def get_at_position(self, position: V2, exclude: list | None = None) -> MapObject | None: + """ + :param position: (V2) The position of the object to find + :param exclude: (list) The objects to ignore while doing the search + :return: (MapObject | None) The first object found at the given position + Returns the first object with a matching position, or None if not found + """ + if exclude is None or len(exclude) == 0: + for v in self._instances: + if isinstance(v, MapObject) and v.position == position: + return v + else: + for v in self._instances: + if isinstance(v, MapObject) and v.position == position and v not in exclude: + return v + return None + + def get_objects(self, object_filter: list | None = None) -> list: + """ + :param object_filter: (list) A filter which will exclude the objects it contains from the result + :return: (list) A list of objects in the running game + Returns a list of all the objects currently in the running game + Excludes objects in filter + """ + if object_filter is None or len(object_filter) == 0: + return list(self._instances) # avoid filtering using an empty filter + res = [] + for v in self._instances: + if v not in object_filter: + res.append(v) + return res + + def on_top(self, obj: MapObject): + """ + :param obj: (MapObject) the object to put in front + Changes the order in the Collection's internal list so the provided object gets rendered last(and on top + of everything else) + """ + if obj in self._instances: + self._instances.remove(obj) + self._instances.append(obj) + + def ray_cast(self, origin: V2, direction: V2, collide_object: MapObject | None = None) -> tuple[V2, list]: + """ + :param origin: (V2) The origin grid position + :param direction: (V2) The direction snapped to grid + :param collide_object: (MapObject) The collider associated with the ray + :return: (V2, list) The position right before hitting an object with collisions and the objects it collided with + Casts a ray in the current map and returns the position right next to the object it hits + Also returns what collided at the end and the object present at the returned position + Note that the collision behaviors are only active if collide_object is set, so calling this without + specifying a collide_object will return an empty list + """ + new_position = origin + direction + hitting_object = self.get_at_position(new_position, [collide_object]) + if (hitting_object and hitting_object.collisions) or not is_in_range(new_position): + colliders = [] # Since multiple objects can collide at once, the colliders are returned as a list + if hitting_object and collide_object: # Check collisions with what the ray cast has hit + colliders.append(hitting_object) + object_overlap = self.get_at_position(origin, [collide_object]) + if object_overlap and collide_object: # Check for an object which is already present at the result position + colliders.append(object_overlap) + return origin, colliders + return self.ray_cast(new_position, direction, collide_object) # Recursive case for next step + + def clear_all(self): + """ + Removes every object from the collection without calling removed() on them + """ + for v in self._instances: # Remove the collection tag inside the objects before actually clearing + if isinstance(v, MapObject): + v.collection = None + self._instances = [] + self.exits = 0 + + +class Switch(MapObject): + """ + The base class for switches. + Switches removes the Switch Walls of the same color when deleted(when the player lands on it). + """ + def __init__(self): + super().__init__() + # new properties + self.color_id = 12 # Defines the color associated with this switch + + def draw(self): + pyxel.circ(self.position.X * CELL_SIZE + 4, self.position.Y * CELL_SIZE + 4, CELL_SIZE // 3 - 1, self.color_id) + + def removed(self): + for v in self.collection.get_objects([self]): + if isinstance(v, SwitchWall) and v.color_id == self.color_id: + self.collection.remove(v) + + def touched(self, collider: MapObject): + if collider.ID == 3: # Check if the player is the collider, as it can't only be that. + self.collection.remove(self) + + +class SwitchWall(MapObject): + """ + A variant of Wall which is influenced by SwitchBlue objects. + """ + def __init__(self): + super().__init__() + self.collisions = True + self.display_name = "Switch Wall" + self.color_id = 12 # Defines the switch color associated with the wall + + def draw(self): + pyxel.rect(self.position.X * CELL_SIZE, self.position.Y * CELL_SIZE, CELL_SIZE, CELL_SIZE, self.color_id) + + +# Object classes definitions + +class Wall(MapObject): + """ + Represents a wall on the map. + """ + def __init__(self): + super().__init__() + self.collisions = True + self.ID = 1 + self.display_name = "Wall" + + def draw(self): + pyxel.rect(self.position.X * CELL_SIZE, self.position.Y * CELL_SIZE, CELL_SIZE, CELL_SIZE, 7) + + +class Exit(MapObject): + """ + Represents an "exit". + """ + def __init__(self): + super().__init__() + self.ID = 2 + self.display_name = "Objective Point" + + def draw(self): + pyxel.rect(self.position.X * CELL_SIZE, self.position.Y * CELL_SIZE, CELL_SIZE, CELL_SIZE, 11) + + def touched(self, collider: MapObject): + if collider.ID == 3: # Check if the player is the collider, as it can't only be that. + self.collection.remove(self) + + +class Player(MapObject): + """ + Represents the player on the map. + """ + def __init__(self): + super().__init__() + self.ID = 3 + self.display_name = "Player" + self.can_be_placed = False + self.can_be_deleted = False + + def draw(self): + pyxel.circ(self.position.X * CELL_SIZE + 4, self.position.Y * CELL_SIZE + 4, 3, 8) + + +class SwitchBlue(Switch): + """ + The blue variant of the Switch object. + """ + def __init__(self): + # We don't do anything else than constructing the super class because the default color is blue + super().__init__() + self.ID = 4 + + +class SwitchWallBlue(SwitchWall): + """ + The blue variant of the SwitchWall object. + """ + def __init__(self): + # Same as for SwitchBlue + super().__init__() + self.ID = 5 + + +# Editor objects + +class Cursor(MapObject): + def __init__(self): + super().__init__() + self.ID = -1 + + def draw(self): + pyxel.circb(self.position.X * CELL_SIZE + 4, self.position.Y * CELL_SIZE + 4, 2, 4) + + +# Wrapping the above classes into a list for the loader to use + +loadable_elements = [ + Wall, + Exit, + Player, + SwitchBlue, + SwitchWallBlue +] + + +def get_type_from_id(element_id: int): + """ + :param element_id: (int) The type id of the element to look for + :return: (MapObject | None) The constructor of the element with the matching type id + Finds an object type with a matching type id and returns it + """ + for v in loadable_elements: + if v().ID == element_id: + return v + return None