""" 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 It is not meant to be mutable """ 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): """ :param obj: (MapObject | Collection) 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 = [] 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) class EditText(MapObject): def __init__(self): super().__init__() self.ID = -2 self._txt = "" self._frame_count = 0 self.frame_limit = 30 def draw(self): if len(self._txt) > 0: self._frame_count += 1 if self._frame_count > self.frame_limit: self._txt = "" return pyxel.text(self.position.X * CELL_SIZE, self.position.Y * CELL_SIZE, self._txt, 7, None) def set_text(self, txt: str): self._txt = txt self._frame_count = 0 # 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 # Gives the editor the elements that can be placed using right mouse button editor_place_elements = [] editor_preview_objects = [] for element in loadable_elements: prev_object = element() if prev_object.can_be_placed: editor_place_elements.append(element) editor_preview_objects.append(prev_object) def editor_next_number(current: int) -> int: """ :param current: (int) The current index used by the editor :return: (int) The next index of the editor_place_elements list Used by the editor to determine the next element that can be placed using the mouse wheel """ if current >= len(editor_place_elements)-1: return 0 return current + 1 def editor_previous_number(current: int) -> int: """ :param current: (int) The current index used by the editor :return: (int) The previous index of the editor_place_elements list Used by the editor to determine the previous element that can be placed using the mouse wheel """ if current <= 0: return len(editor_place_elements) - 1 return current - 1