nfx
4 weeks ago
8 changed files with 1013 additions and 0 deletions
Binary file not shown.
Binary file not shown.
@ -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") |
@ -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) |
@ -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) |
@ -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] |
||||
] |
@ -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) |
@ -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 |
Loading…
Reference in new issue