You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

379 lines
14 KiB

"""
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