tzed

Simple story-driven open world 2D CRPG.
Log | Files | Refs | README | LICENSE

commit 073889e774ef6fb9084515296c89e351bec55c9d
parent 5e082e72fb8dabc2bc095f3cc456143f62c23cee
Author: Erik Letson <hmagellan@hmagellan.com>
Date:   Sat,  5 Jun 2021 13:10:10 -0500

checkpoint in basic engine implement

Diffstat:
Asrc/bus.py | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/camera.py | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/entity.py | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/envvars.py | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/game.py | 7+++++++
Asrc/images.py | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/interface.py | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/manager.py | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/sound.py | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/subsystem.py | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1043 insertions(+), 0 deletions(-)

diff --git a/src/bus.py b/src/bus.py @@ -0,0 +1,129 @@ +import pygame +from . import subsystem +from .envvars import * + +########## +# bus.py # +########## + +# This file contains: +# 1. The Bus class, which is an abstraction of a communication bus +# 2. The SystemBus subsystem, which handles subsystem communication +# 3. The ManagerBus subsystem, which handles inter-manager/inter-managed communication + +############################# +# Section 1 - The Bus class # +############################# + +class Bus(subsystem.GameSubsystem): + + def __init__(self, game, name, bus = None): + + # Parent init + super().__init__(game, name, bus) + + # Collection of elements + self.elements = {} + + def enter_element(self, element_name): + """ + Make an entry in the records for the given element. This + entry is checked for existence in many cases before doing + other things. This method should be called when an element + is created MOST OF THE TIME. + """ + if element_name not in self.elements.keys(): + self.elements[element_name] = {} # Initial value is empty dict + else: + raise GameBusError(self.name + "BUS SAYS: " + element_name + " already in records!") + + def record(self, element_name, data): + """ + Record some data about a given element, as long as it has + already been entered in the records. + """ + if element_name in self.elements.keys(): + if type(data) == dict: + self.elements[element_name] = data + else: + raise GameBusError("BUS SAYS: Provided data: " + data + " from " + element_name + " not dict type!") + else: + raise GameBusError("BUS SAYS: " + element_name + " not in records! Unable to record data!") + +################################### +# Section 2 - The SystemBus class # +################################### + +class SystemBus(Bus): + """ + Communication bus that game elements use to + talk to game subsystems. Less featureful than + the ManagerBus class. + """ + # What SHOULD go through SystemBus: + # * Subsystem-to-Subsystem communication + # * ANY object talking to a Subsystem BESIDES Game + # What SHOULD NOT go through ManagerBus: + # * Direct communication from Game to a Subsystem + + def __init__(self, game, name, bus = None): + + # Parent init + super().__init__(game, name, bus) + + # Collection of systems + self.elements = {} + + # Performs: All callable non-return behaviors of subsystems. + +#################################### +# Section 3 - The ManagerBus class # +#################################### + +class ManagerBus(Bus): + """ + Communication bus that managers talk to each + other and their own subordinate objects through. + """ + # What SHOULD go through ManagerBus: + # * Manager-to-Manager communication + # * Manager-to-external-object communication + # * ANY object talking to a manager BESIDES Game + # What SHOULD NOT go through ManagerBus: + # * Direct communication from Game to a manager + # * A Manager talking to an object it manages + # * An object talking to its own manager + + def __init__(self, game, name, bus): + + # Parent init + super().__init__(game, name, bus) + + # Collection of managers + self.elements = {} + + def fetch(self, element, value): + """ + Fetch a given value from a manager. Refers to the bus's + internal records of current object values, updated by the + manager itself via the "expose()" functionality of the manager + objects. + """ + if element in self.records.keys(): + if value in self.records[manager].keys(): + return self.records[manager][value] + else: + raise GameBusError("BUS SAYS: " + value + " not in " + manager + " data record!") + else: + raise GameBusError("BUS SAYS: " + manager + " not in records! Unable to fetch data!") + + # Checks: Computes & returns handled partially by Bus itself. + # Checks are basically the halfway between fetches and performs, + # meaning they do something with the manager they talk to, check + # out that value, and return some info based on that. The checks + # are dynamic, meaning they are not best handled by static fetching. + # check_is_* = return bool + # check_for_* = return something else + + # Performs: All callable non-return behaviors of managers. + diff --git a/src/camera.py b/src/camera.py @@ -0,0 +1,78 @@ +import pygame +from . import subsystem +from .envvar import * + +############# +# camera.py # +############# + +# This file contains: +# 1. The GameCamera class, which manages the camera surface + +#################################### +# Section 1 - The GameCamera class # +#################################### + +class GameCamera(subsystem.GameSubsystem): + """ + Object that controls the camera surface and + moves it in relation to input. The camera + surface is then drawn onto the game's real + screen at an offset determined by input. + """ + + def __init__(self, game, name, bus): + + # Parent init + super().__init__(game, name, bus) + + # Camera surface values + self.camera_surface = pygame.Surface((0, 0)).convert() # This surface is initialized whenever a new map or other game area is loaded + self.camera_surface_rect = self.camera_surface.get_rect() + self.camera_surface_offset = (0, 0) # The rect topleft is never changed; rather, this value is used by game to draw the camera surface at an offset + + def load_camera_surface(self, dimensions, init_offset = (0, 0)): + """ + Load up the camera surface as a new PyGame + surface object of the necessary size. Also + resets the camera offset. + """ + self.camera_surface = pygame.Surface(dimensions).convert() + self.camera_surface_rect = self.camera_surface.get_rect() + self.camera_surface_offset = init_offset + + def move_offset(self, rel_offset, speed_multiple = 1): + """ + Move the offset by the given relative offset. + Speed can also be increased or decreased by + passing a multiple here. Handles not moving + the camera view outside of game screen + completely. + """ + changex = 0 + changey = 0 + if not ((self.camera_surface_offset[0] <= -(SCREEN_WIDTH / 2) and rel_offset[0] < 0) or (self.camera_surface_offset[0] >= (SCREEN_WIDTH / 2) and rel_offset[0] > 0)): + changex = rel_offset[0] * speed_multiple + if not ((self.camera_surface_offset[1] <= -SCREEN_HEIGHT and rel_offset[1] < 0) or (self.camera_surface_offset[1] >= (SCREEN_HEIGHT / 2) and rel_offset[1] > 0)): + changey = rel_offset[1] * speed_multiple + self.camera_surface_offset = (self.camera_surface_offset[0] + changex, self.camera_surface_offset[1] + changey) + + def snap_to_position(self, position): + """ + Snap the camera to a position, relative + to the screen. The given position should + be coordinates on the camera surface, and + this method will compute that to produce + an offset such that the camera surface + appears centered to that position in + relation to the game screen. + """ + self.camera_surface_offset = ((SCREEN_WIDTH / 2) - position[0], (SCREEN_HEIGHT / 2) - position[1]) + + def update_camera(self, surface = None): + """ + Update and draw the camera contents. + """ + if surface != None: + surface.blit(self.camera_surface, self.camera_surface_offset) + diff --git a/src/entity.py b/src/entity.py @@ -0,0 +1,207 @@ +import pygame, json, os +from . import manager +from .constants import * + +############# +# entity.py # +############# + +# This file contains: +# 1. The Entity class that is the extension of pygame.Sprite used throughout the game +# 2. Various kinds of specialized entity classes that don't fit in other source files + +# TODO: This should eventually use LayeredDirty sprites for performance reasons + +############################ +# Section 1 - Entity class # +############################ + +class Entity(pygame.sprite.DirtySprite): + """ + The parent of all visible objects. The Entity object itself is + essentially an extended, customised version of the PyGame Sprite + object. Entity supports animations, motions (movement over time), + and tile operations, but none of that is required. + """ + + def __init__(self, sheet, sprite = (0, 0), animation = None, animated = False): + + # Parent initialization + super().__init__() + + # Saved values + self.sheet = sheet + self.sprite = sprite + self.image = sheet.sprites[sprite] + self.rect = self.image.get_rect() + self.rect.topleft = (0, 0) + self.flipped = (False, False) + self.custom_flags = None # Used to pass special info to Entities + + # DirtySprite class defaults + self.visible = 1 + self.dirty = 0 + self.layer = 0 + + # Animation values + self.animated = animated + self.animation = {} + self.animation_timer = 0 + self.animation_frame = 0 + if animated: + self.set_animation(animation, animated) + + # Motion values + self.motion = {} + self.motion_timer = 0 + + # Tile values + self.tile_pos = (-1, -1) + self.tile_gid = None + + def set_image(self, sheet, sprite = (0, 0)): + """ + Set the image of the Entity to a new image. + """ + self.sheet = sheet + self.sprite = sprite + self.image = sheet.sprites[sprite] + old_tp = self.rect.topleft + self.rect = self.image.get_rect() + self.rect.topleft = old_tp + + def set_sprite(self, sprite): + """ + Set the Entity's sprite to another one on the board. + The argument 'sprite' is a tuple (x, y). This is used + to assign an image from the saved sheet to non-animated + Entity objects. + """ + if sprite in self.sheet.sprites.keys(): + self.image = self.sheet.sprites[sprite] + self.sprite = sprite + + def set_animation(self, new_animation, play = False, init_frame = 0): + """ + Assign a new animation to this Entity and configure all + necessary values to get it playing. + """ + self.animation = new_animation + self.animation_frame = init_frame + self.animation_timer = new_animation[init_frame]["timer"] + self.image = self.sheet.sprites[new_animation[init_frame]["sprite"]] + self.animated = play + + def set_motion(self, target_pos, speed): + """ + Assign a new motion to this Entity and start playing it. + Unlike animations, motions must be played if assigned. Note: + Motions go by rect center, rather than topleft. + """ + self.motion = { + "target" : target_pos, + "speed" : speed, + "direction" : ((target_pos[0] - self.rect.center[0]) / speed, (target_pos[1] - self.rect.center[1]) / speed), + "timer" : speed + } + + def set_position(self, pos): + """ + Assign the rect topleft to a raw position tuple, independent + of e.g. a tile. + """ + self.rect.topleft = pos + + def set_center_position(self, pos): + """ + Assign the rect center to a raw position tuple, independent + of e.g. a tile. + """ + self.rect.center = pos + + def set_flip(self, hz = None, vt = None): + """ + Set the flip tuple valse to True or False. hz is for horizontal + flipping and vt is vertical. If either is None, that value will + not be changed. + """ + if hz == None: + hz = self.flipped[0] + if vt == None: + vt = self.flipped[1] + self.flipped = (hz, vt) + + def animate(self): + """ + Play the current animation. This method is called as part of + the update() logic. + """ + if self.animation_timer > 0: + self.animation_timer -= 1 + else: + if self.animation_frame < len(self.animation) - 1: + self.animation_frame += 1 + else: + self.animation_frame = 0 + self.animation_timer = self.animation[self.animation_frame]["timer"] + self.image = self.sheet.sprites[self.animation[self.animation_frame]["sprite"]] + + def motion_move(self): + """ + Perform current motion, if it is set. Note: Motions go by + rect center, rather than topleft. + """ + if self.motion != {} and not self.rect.collidepoint(self.motion["target"]): + if self.motion_timer == 0: + mx = self.rect.center[0] + self.motion["direction"][0] + my = self.rect.center[1] + self.motion["direction"][1] + self.rect.center = (mx, my) + self.motion_timer = self.motion["speed"] + else: + self.motion_timer -= 1 + else: + if self.motion != {}: + self.set_center_position(self.motion["target"]) # Make sure we end up in exactly the right spot + self.motion = {} + + def snap_to_tile(self): + """ + Snap the Entity to its current tile. + """ + if self.tile_pos != (-1, -1): + self.set_position((self.tile_pos[0] * TILE_WIDTH, self.tile_pos[1] * TILE_HEIGHT)) + + def assign_tile(self, tile_pos, tile_gid): + """ + Assign a tile as this entity object's occupied tile. + Assume the values are legitimate. + """ + self.tile_pos = tile_pos + self.tile_gid = tile_gid + + def act(self): + """ + This method is called as part of update() and is meant + to be overwritten in subclasses with any logic that needs + to happen each update. + """ + pass + + def update(self, surface = None, to_animate = True): + """ + Draw the Entity to the surface. Also calls act() for update + logic for specific Entity children, and animate() to animate + the sprite image. + """ + self.act() + if surface != None: + self.motion_move() + if self.animated and to_animate: + self.animate() + surface.blit(pygame.transform.flip(self.image, self.flipped[0], self.flipped[1]), self.rect) + +######################################### +# Section 2 - Various Entity subclasses # +######################################### + +# TODO: diff --git a/src/envvars.py b/src/envvars.py @@ -1,4 +1,52 @@ +import os, enum, json, pathlib + +############## +# envvars.py # +############## + +# This file contains: +# 1. Important environment values that exist independent of any one source file + +# TODO: This file should source from a settings file + +######################### +# Section 1 - Constants # +######################### + # Game settings SCREEN_WIDTH = 1024 SCREEN_HEIGHT = 768 FRAMERATE = 60 + +# Tile settings +BASE_TILE_WIDTH = 16 +BASE_TILE_HEIGHT = 16 +TILE_SCALE_FACTOR = 4 +COLORKEY = (255, 0, 255) + +# File paths +DATA_PATH = os.path.join(os.getcwd(), "data") +IMAGE_PATH = os.path.join(DATA_PATH, "img") +SOUND_PATH = os.path.join(DATA_PATH, "snd") +FONT_PATH = os.path.join(DATA_PATH, "font") +TILE_PATH = os.path.join(DATA_PATH, "tsx") +JSON_PATH = os.path.join(DATA_PATH, "json") +SAVE_PATH = os.path.join(str(pathlib.Path.home), ".local", "share", "heart-of-gold", "savegames") + +# Stats constants +# TODO: Could be totally changed going forward +CORE_STATS = ["STR", "FIN", "MAG", "LUK"] +METER_STATS = ["HP", "MP"] +META_STATS = ["LVL", "EXP", "RNG"] + +# Enums +STATE_MODES = enum.Enum('STATE_MODES', 'Main_Menu_Mode Overworld_Mode Location_Mode Basic_Battle_Mode Location_Battle_Mode') +CTRL_MODES = enum.Enum('CTRL_MODES', 'No_Control Main_Menu_Normal') +GAME_EFFECTS = enum.Enum('GAME_EFFECTS', 'ef_game_dummy ef_game_quit ef_game_switch_mode ef_game_switch_control') +TEAMS = enum.Enum('TEAMS', 'Player Ally Neutral Enemy Other') + +# Error types +class GameBusError(Exception): + """ + Raised when an error occurs as part of Bus operation. + """ diff --git a/src/game.py b/src/game.py @@ -1,4 +1,5 @@ import pygame +from . import interface, bus, camera from .envvars import * ########### @@ -29,6 +30,12 @@ class Game(object): self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) self.frame_clock = pygame.time.Clock() + # Subsystem objects + self.subsystem_bus = bus.SystemBus(self) + self.manager_bus = bus.ManagerBus(self) + self.camera = camera.Camera(self) + self.interface = interface.GameInterface(self, self.manager_bus, self.subsystem_bus) + def shift_frames(self, framerate = FRAMERATE): """ Shift to the next frame of the game at the specified diff --git a/src/images.py b/src/images.py @@ -0,0 +1,171 @@ +import pygame, os, json +from . import manager +from .envvars import * + +############# +# images.py # +############# + +# This file contains: +# 1. The 'SheetManager' class, which is a Manager object that handles sheets (e.g. loading) +# 2. The 'Sheet' class, which is used to represent enumerated spritesheets + +################################### +# Section 2 - SheetManager Object # +################################### + +class SheetManager(manager.Manager): + """ + SheetManager handles loading and managing sheets. It is + talked to in order to get loaded sheets. SheetManager also + knows all animations and is talked to to get info about + them. + """ + + def __init__(self, game, bus, camera, name): + + super().__init__(game, bus, camera, name) + + # Important values + self.sheets_def = {} + self.loaded_sheets = {} + self.anims_def = {} + self.animations = {} + + # This manager should perform an initial update + self.update(None) + + def load_sheets_from_json(self, sheet_json): + """ + Load sheets from the given definition JSON file. Note + that sheets must be loaded AFTER animations. + """ + with open(os.path.join(JSON_PATH, sheet_json)) as df: self.sheets_def = json.load(df) + + for j in self.sheets_def: + an = {} + if self.sheets_def[j]["anim_class"] != None: + an = self.animations[self.sheets_def[j]["anim_class"]] + self.loaded_sheets[j] = Sheet(self, self.sheets_def[j]["filename"], j, tuple(self.sheets_def[j]["dimensions"]), self.sheets_def[j]["total_sprites"], an, self.sheets_def[j]["alpha"]) + + def load_animations_from_json(self, anim_json): + """ + Load animations from the given definition JSON file. + Note that animations must be loaded BEFORE sheets. + """ + with open(os.path.join(JSON_PATH, anim_json)) as anf: self.anims_def = json.load(anf) + + self.animations = self.anims_def + for c in self.anims_def: + for a in self.anims_def[c]: + for i in range(0, len(self.anims_def[c][a])): + for v in self.anims_def[c][a][i]: + if type(self.anims_def[c][a][i][v]) == list: + self.animations[c][a][i][v] = tuple(self.anims_def[c][a][i][v]) + + def load_image(self, filename, alpha = False): + """ + Load an image file as a PyGame image. This is generic + and can function even outside of the normal sheet + dynamic that the game uses. + """ + if alpha: + im = pygame.image.load(os.path.join(IMAGE_PATH, filename)).convert_alpha() + else: + im = pygame.image.load(os.path.join(IMAGE_PATH, filename)).convert() + return im + + def get_sheet(self, name): + """ + Return the sheet matching name if it exists. + """ + if name in self.loaded_sheets.keys(): + return self.loaded_sheets[name] + + def expose(self): + """ + Expose sheet info to the ManagerBus. + """ + data = { + "sheets" : self.loaded_sheets, + "animations" : self.animations + } + self.bus.record(self.name, data) + +############################ +# Section 3 - Sheet Object # +############################ + +class Sheet(object): + """ + Class for the enumerated spritesheets that are used to get + images for entities. Supports regularized spritesheets only + (that is, all sprites are the same dimensions). + """ + + def __init__(self, manager, filename, name, sprite_size, total_sprites, animations, alpha): + + # Important values + self.manager = manager + self.filename = filename + self.name = name + self.sprite_dimensions = sprite_size + self.total_sprites = total_sprites + self.sprites = {} + self.animations = animations + self.alpha = alpha + + # Try and load all sprites on the sheet, or log if we failed + if not self.load_sheet(filename, sprite_size, total_sprites): + self.sprites = {} + print("Failed to load sheet: " + filename) + + def load_sheet(self, filename, sprite_size, total_sprites): + """ + Load a sheet and divide it into subsurfaces for use as + images by sprite entities. + """ + + # First, attempt to load the image file. Failing that, create + # a sheet as a NoneType object + try: + self.sheet = self.manager.load_image(filename, self.alpha) + except: + self.sheet = None + + # Next, if the sheet exists, divide it into sprites + if self.sheet != None: + self.sprites = {} + + # Values to track progress + x = 0 + y = 0 + + # While there are still more sprites to load, load them + while len(self.sprites) < total_sprites: + + # Get a rect that can be used to make a subsurface of the sheet + new_rect = pygame.Rect((x * sprite_size[0], y * sprite_size[1]), sprite_size) + + # Load image, store in our sprites dict, and set colorkey or alpha + if not self.alpha: + self.sprites[(x, y)] = self.sheet.subsurface(new_rect).convert() + self.sprites[(x, y)].set_colorkey(COLORKEY) + else: + self.sprites[(x, y)] = self.sheet.subsurface(new_rect).convert_alpha() + + # Scoot over to the right + x += 1 + + # If we're hanging off the right side, scoot down and start + # over again from the far left + if x * sprite_size[0] >= self.sheet.get_width(): + x = 0 + y += 1 + + # After the while loop, return True for success + return True + + # No sheet exists, return False for failure + else: + return False diff --git a/src/interface.py b/src/interface.py @@ -0,0 +1,145 @@ +import pygame +from . import subsystem +from .envvars import * + +################ +# interface.py # +################ + +# This file contains: +# 1. The GameInterface class, which handles all kinds of interaction from the player + +####################################### +# Section 1 - The GameInterface class # +####################################### + +class GameInterface(subsystem.GameSubsystem): + """ + GameInterface handles all PyGame events, meaning + it is responsible for all input and the responses + to that input. GameInterface takes extra arguments + compared to other subsystems, representing its need + to directly access manager calls via bus and the + game camera. + """ + + def __init__(self, game, name, bus, manager_bus): + + # Parent init + super().__init__(game, name, bus) + + # Busses for direct communication + self.manager_bus = manager_bus + + # Others + self.left_double_clicking = False + self.left_double_click_timer = 0 + self.left_double_click_mousepos = None + self.right_double_clicking = False + self.right_double_click_timer = 0 + self.right_double_click_mousepos = None + self.old_mousepos = None + self.key_bools = [ False for k in range(0, 350) ] + + def handle_events(self, events): + """ + Handle any kind of PyGame event and react appropriately. + """ + for event in events: + if event.type == pygame.KEYDOWN: + self.handle_key_press(event) + elif event.type == pygame.KEYUP: + self.handle_key_release(event) + elif event.type == pygame.MOUSEBUTTONDOWN: + self.handle_mouse_click(event) + elif event.type == pygame.MOUSEBUTTONUP: + self.handle_mouse_release(event) + elif event.type == pygame.QUIT: + self.game.quit_game() + + def handle_key_press(self, event): + """ + React to a key being pressed. + """ + if event.key < len(self.key_bools): + self.key_bools[event.key] = True + + def handle_key_release(self, event): + """ + React to a key being released. + """ + if event.key < len(self.key_bools): + self.key_bools[event.key] = False + + def react_to_keys(self): + """ + React to certain pressed/not-pressed statuses + of keys on a mode-by-mode basis. Called during + update. + """ + pass + + def handle_mouse_click(self, event): + """ + React to a mousebutton being clicked. + """ + # First, get important mouse positional info, namely unoffset mouse position and camera-offset mouse position + mouseraw = pygame.mouse.get_pos() + mousepos = (mouseraw[0] - self.camera.camera_surface_offset[0], mouseraw[1] - self.camera.camera_surface_offset[1]) + + # Handle left-click + if event.button == 1: + + # Set up for double click + if self.left_double_click_timer == 0 and not self.left_double_clicking: + self.left_double_click_timer = 15 + self.left_double_click_mousepos = mousepos + elif not self.left_double_clicking and mousepos == self.left_double_click_mousepos: + self.left_double_clicking = True + self.left_double_click_timer = 0 + + # Handle right-click + elif event.button == 3: + + # Set up for double click + if self.right_double_click_timer == 0 and not self.right_double_clicking: + self.right_double_click_timer = 15 + self.right_double_click_mousepos = mousepos + elif not self.right_double_clicking and mousepos == self.right_double_click_mousepos: + self.right_double_clicking = True + self.right_double_click_timer = 0 + + # Keepover + self.old_mousepos = mousepos + + def handle_mouse_release(self, event): + """ + React to a mousebutton being released. + """ + # First, get important mouse positional info, namely unoffset mouse position and camera-offset mouse position + mouseraw = pygame.mouse.get_pos() + mousepos = (mouseraw[0] - self.camera.camera_surface_offset[0], mouseraw[1] - self.camera.camera_surface_offset[1]) + + # Handle left-release + if event.button == 1: + pass + + # Handle right-release + elif event.button == 3: + pass + + def update_interface(self): + """ + Update interface elements (such as the cursor) once + per frame. This is not the same as a drawing update for + e.g. an Entity, and is logic-only. + """ + # UNIVERSAL UPDATES + # Doubleclick countdown + if self.left_double_click_timer > 0: + self.left_double_click_timer -= 1 + else: + self.left_double_clicking = False + + # React to keys + self.react_to_keys() diff --git a/src/manager.py b/src/manager.py @@ -0,0 +1,112 @@ +import pygame +from . import subsystem +from .envvars import * + +############## +# manager.py # +############## + +# This file contains: +# 1. The Manager object, which is the parent of other managers that perform functions directly subordinate to the Game object. + +################################## +# Section 1 - The Manager Object # +################################## + +class Manager(subsystem.GameSubsystem): + """ + Fairly simple parent type for the various + managers of different game elements, such as + the play board or the UI. + """ + # What SHOULD be a Manager: + # * ANY object that both MANAGES SOME GAME + # RESOURCE (image, entity, etc.) and NEEDS + # TO BE ABLE TO COMMUNICATE WITH OBJECTS + # OUTSIDE ITS OWN SCOPE (that is, needs + # bus access). Additionally, Managers will + # commonly have to draw entities to the + # screen (though not all managers do this). + + # Properties + @property + def activated(self): + return self._activated + @activated.setter + def activated(self, x): + self._activated = x + + @property + def effectual(self): + return self._effectual + @effectual.setter + def effectual(self, x): + self._effectual = x + + # Initialization + def __init__(self, game, name, bus, system_bus): + + # Initial values + super().__init__(game, name, bus) + self.system_bus = system_bus + + # Property defaults + self._activated = True + self._effectual = False + + def expose(self): + """ + Expose info about this object to the ManagerBus + object. Overwritten in child objects. Expose is + called for sure once per frame in an active + manager via "update()", but it is safe to call + it elsewhere as well. + """ + pass + + def trigger_effects(self, effect_list): + """ + Triggers game effects as defined in JSONs. + This method can be extended in child objects + that need to be able to trigger unique + effects by using the effect_extension method. + """ + # effect_list is ALWAYS a LIST of DICTS + if self.activated and self.effectual: + for ef in effect_list: + if ef["call"] == GAME_EFFECTS.ef_game_quit.name: + self.game.quit_game() + # Switch mode ALWAYS has a two-part list for its data, with the second part being able to + # be whatever game needs for that particular mode (even compound data types such as list/dict) + elif ef["call"] == GAME_EFFECTS.ef_game_switch_mode.name: + self.game.switch_mode(STATE_MODES[ef["data"][0]], ef["data"][1]) + elif ef["call"] == GAME_EFFECTS.ef_game_switch_control.name: + self.game.control_mode = CTRL_MODES[ef["data"]] + else: + self.effect_extension(ef) + + def effect_extension(self, effect): + """ + Target for extension of trigger_effects method. + This method should have a single branching + conditional statement inside it that is unique + to the manager that needs extra effects. It is + used to check only one effect at a time and should + not contain a loop like trigger_effects. + """ + pass + + def update(self, surface): + """ + Generic update logic for managers. + """ + if self.activated: + self.expose() + self.update_managed(surface) + + def update_managed(self, surface): + """ + Abstract method to be overwritten with + update logic in child objects. + """ + pass diff --git a/src/sound.py b/src/sound.py @@ -0,0 +1,60 @@ +import pygame, os, json +from . import subsystem +from .constants import * + +############ +# sound.py # +############ + +# This file contains: +# 1. SoundManager, which loads up all sounds and is then called to play them through the pygame mixer. + +############################ +# Section 1 - SoundManager # +############################ + +class SoundSystem(subsystem.GameSubsystem): + """ + This object loads sounds as defined in JSON + files and is called to play them. + """ + + def __init__(self, game, name, bus): + + # Parent initialization + super().__init__(game, name, bus) + + # Important values + self.sounds = {} + self.channels = [pygame.mixer.Channel(x) for x in range(0, pygame.mixer.get_num_channels() - 1)] + + def load_sounds_from_json(self, soundjson): + """ + Load up sounds from a definition JSON + file. + """ + with open(os.path.join(JSON_PATH, soundjson)) as sj: j = json.load(sj) + + for s in j["sounds"]: + self.sounds[s["name"]] = pygame.mixer.Sound(os.path.join(SOUND_PATH, s["filename"])) + self.sounds[s["name"]].set_volume(float(s["volume"])) + + def play_sound(self, soundname, channel = None, concurrent = False): + """ + Play a loaded sound by name. If the sound is + already playing, stop it unless concurrent is + True. A new channel should be chosen to play + the sound on dynamically, unless channel is + a legal channel id int. + """ + # TODO: There could (should?) be some channel-checking logic here + if soundname != None: + if channel != None and not self.channels[channel].get_busy(): + try: + self.channels[channel].play(self.sounds[soundname]) + except IndexError: + # TODO: Should be bus error here + print("Failed to play sound " + soundname + " on channel " + channel) + elif concurrent or self.sounds[soundname].get_num_channels() == 0: + self.sounds[soundname].play() + diff --git a/src/subsystem.py b/src/subsystem.py @@ -0,0 +1,86 @@ +import pygame, os, json +from .envvars import * + +################ +# subsystem.py # +################ + +# This file contains: +# 1. The generic GameSubsystem class, which abstract subsystems (like GameInterface) are children of, as well as all Managers +# 2. The SaveSystem class, which handles saving and loading games + +####################################### +# Section 1 - The GameSubsystem class # +####################################### + +class GameSubsystem(object): + """ + Abstract class subordinate to game which + Managers and other subsystems are specialized + versions of. GameSubsystem is pretty much the + most abstract possible parent for these kinds + of objects and is very simple. + """ + # What SHOULD be a Subsystem: + # * Any object that CONTROLS A CRITICAL + # GAME FUNCTION, IS DIRECTLY SUBORDINATE + # TO THE Game OBJECT, and DOES NOT + # MANAGE ANY ENTITIES OR RESOURCES BY + # ITSELF. Game Subsystems can and often + # do need ManagerBus access, but they don't + # manage any objects themselves, nor do + # they expose their own data to the + # ManagerBus. + + def __init__(self, game, name, bus = None): + + self.game = game + self.name = name + self.bus = bus + + self.record_self(bus) + + def record_self(self, bus = None): + """ + Record this system in a given bus, for communication + purposes. + """ + if bus != None: + bus.enter_element(self.name) + +#################################### +# Section 2 - The SaveSystem class # +#################################### + +class SaveSystem(GameSubsystem): + """ + The SaveSystem object handles saving to and loading + from a game save JSON file. This large JSON file is + subdivided into smaller objects called definitions + that are transformed at load time to Python dicts. + These definitions are passed to Game, managers, and + other subsystems in order to generate the gameplay + environment. + """ + + def __init__(self, game, name, bus): + + # Parent init + super().__init__(game) + + # Environment + self.active_save_env = {} + + def save_to_file(self, filename): + # TODO: Check if it exists and prompt to overwrite (maybe in a another object???) + # TODO: Make cross-platform compatible + with open(os.path.join(SAVE_PATH, filename)) as sf: json.dump(self.active_save_env, sf) + + def load_from_file(self, filename): + with open(os.path.join(SAVE_PATH, filename)) as lf: self.active_save_env = json.load(lf) + + def apply_environment(self): + pass + + def update_environment(self, key, value): + pass