def startup(self, *args, **kwargs): super(CombatTargetMenuState, self).startup(*args, **kwargs) # used to determine how player can target techniques self.user = kwargs.get("user") self.action = kwargs.get("action") self.player = kwargs.get("player") # load and scale the menu borders border = tools.load_and_scale(self.borders_filename) self.border = GraphicBox(border, None, None)
def startup(self, **kwargs): super(MonsterMenuState, self).startup(**kwargs) # make a text area to show messages self.text_area = TextArea(self.font, self.font_color, (96, 96, 96)) self.text_area.rect = pygame.Rect( tools.scale_sequence([20, 80, 80, 100])) self.sprites.add(self.text_area, layer=100) # Set up the border images used for the monster slots self.monster_slot_border = {} self.monster_portrait = pygame.sprite.Sprite() self.hp_bar = HpBar() # load and scale the monster slot borders root = "gfx/ui/monster/" border_types = ["empty", "filled", "active"] for border_type in border_types: filename = root + border_type + "_monster_slot_border.png" border = tools.load_and_scale(filename) filename = root + border_type + "_monster_slot_bg.png" background = tools.load_image(filename) window = GraphicBox(border, background, None) self.monster_slot_border[border_type] = window # TODO: something better than this global, load_sprites stuff for monster in self.game.player1.monsters: monster.load_sprites()
def show_combat_dialog(self): """ Create and show the area where battle messages are displayed """ # make the border and area at the bottom of the screen for messages x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w, h // 4) rect.bottomright = w, h border = tools.load_and_scale(self.borders_filename) self.dialog_box = GraphicBox(border, None, self.background_color) self.dialog_box.rect = rect self.sprites.add(self.dialog_box, layer=100) # make a text area to show messages self.text_area = TextArea(self.font, self.font_color) self.text_area.rect = self.dialog_box.calc_inner_rect(self.dialog_box.rect) self.sprites.add(self.text_area, layer=100)
def load_graphics(self): """ Loads all the graphical elements of the menu Will load some elements from disk, so needs to be called at least once. """ # load and scale the _background background = None if self.background_filename: background = tools.load_image(self.background_filename) # load and scale the menu borders border = None if self.draw_borders: border = tools.load_and_scale(self.borders_filename) # set the helper to draw the _background self.window = GraphicBox(border, background, self.background_color) # handle the arrow cursor image = tools.load_and_scale(self.cursor_filename) self.arrow = MenuCursor(image)
class Menu(state.State): """ A class to create menu objects. Menus are a type of game state. Menus that are the top state will receive player input and respond to it. They may be stacked, so that menus are nested. :background: String :ivar rect: The rect of the menu in pixels, defaults to 0, 0, 400, 200. :ivar state: An arbitrary state of the menu. E.g. "opening" or "closing". :ivar selected_index: The index position of the currently selected menu item. :ivar menu_items: A list of available menu items. """ # defaults for the menu columns = 1 min_font_size = 4 draw_borders = True background = None # Image used to draw the background background_color = 248, 248, 248 # The window's background color background_filename = None # File to load for image background menu_select_sound_filename = "sounds/interface/menu-select.ogg" font_filename = "resources/font/PressStart2P.ttf" borders_filename = "gfx/dialog-borders01.png" cursor_filename = "gfx/arrow.png" cursor_move_duration = .20 default_character_delay = 0.05 shrink_to_items = False # fit the border to contents escape_key_exits = True # escape key closes menu animate_contents = False # show contents while window opens touch_aware = False # if true, then menu items can be selected with the mouse/touch def startup(self, *items, **kwargs): self.rect = self.rect.copy() # do not remove! self.selected_index = 0 # track which menu item is selected self.state = "closed" # closed, opening, normal, disabled, closing self.window = None # draws borders, background self._show_contents = False # draw menu items, or not self._needs_refresh = False # refresh layout on next draw self._anchors = dict() # used to position the menu/state self.__dict__.update(kwargs) # may be removed in the future # holds sprites representing menu items self.create_new_menu_items_group() self.set_font() # load default font self.load_graphics() # load default graphics self.reload_sounds() # load default sounds def create_new_menu_items_group(self): """ Create a new group for menu items to be contained in Override if you need special placement for the menu items. :return: None """ # contains the selectable elements of the menu self.menu_items = VisualSpriteList(parent=self.calc_menu_items_rect) self.menu_items.columns = self.columns # generally just for the cursor arrow self.menu_sprites = RelativeGroup(parent=self.menu_items) def shutdown(self): """ Clear objects likely to cause cyclical references :returns: None """ self.sprites.empty() self.menu_items.empty() self.menu_sprites.empty() self.animations.empty() del self.arrow del self.menu_items del self.menu_sprites def start_text_animation(self, text_area): """ Start an animation to show textarea, one character at a time :param text_area: TextArea to animate :type text_area: core.components.ui.text.TextArea :rtype: None """ def next_character(): try: next(text_area) except StopIteration: pass else: self.task(next_character, self.character_delay) self.character_delay = self.default_character_delay self.remove_animations_of(next_character) next_character() def animate_text(self, text_area, text): """ Set and animate a text area :param text: Test to display :type text: basestring :param text_area: TextArea to animate :type text_area: core.components.ui.text.TextArea :rtype: None """ text_area.text = text self.start_text_animation(text_area) def alert(self, message): """ Write a message to the first available text area Generally, a state will have just one, if any, text area. The first one found will be use to display the message. If no text area is found, a RuntimeError will be raised :param message: Something interesting, I hope. :type message: basestring :returns: None """ def find_textarea(): for sprite in self.sprites: if isinstance(sprite, TextArea): return sprite logger.error("attempted to use 'alert' on state without a TextArea", message) raise RuntimeError self.animate_text(find_textarea(), message) def initialize_items(self): """ Advanced way to fill in menu items For menus that change dynamically, use of this method will make changes to the menu easier. :return: """ pass def reload_items(self): """ Empty all items in the menu and re-add them Only works if initialize_items is used :return: None """ self._needs_refresh = True items = self.initialize_items() if items: self.menu_items.empty() for item in items: self.add(item) number_items = len(self.menu_items) if self.menu_items and self.selected_index >= number_items: self.change_selection(number_items - 1) def build_item(self, label, callback, icon=None): """ Create a menu item and add it to the menu :param label: Some text :param icon: pygame surface (not used yet) :param callback: callback to use when selected :return: Menu Item """ image = self.shadow_text(label) item = MenuItem(image, label, None, callback) self.add(item) def add(self, item): """ Add a menu item :type item: core.components.menu.MenuItem :return: None """ self.menu_items.add(item) self._needs_refresh = True def fit_border(self): """ Resize the window border to fit the contents of the menu :return: """ # get bounding box of menu items and the cursor center = self.rect.center rect1 = self.menu_items.calc_bounding_rect() rect2 = self.menu_sprites.calc_bounding_rect() rect1 = rect1.union(rect2) # expand the bounding box by the border and some padding # TODO: do not hardcode these values # border is 12, padding is the rest rect1.width += tools.scale(18) rect1.height += tools.scale(19) rect1.topleft = 0, 0 # set our rect and adjust the centers to match self.rect = rect1 self.rect.center = center # move the bounding box taking account the anchors self.position_rect() def reload_sounds(self): """ Reload sounds :returns: None """ self.menu_select_sound = tools.load_sound(self.menu_select_sound_filename) def shadow_text(self, text, bg=(192, 192, 192)): """ Draw shadowed text :param text: Text to draw :param bg: :returns: """ top = self.font.render(text, 1, self.font_color) shadow = self.font.render(text, 1, bg) offset = layout((0.5, 0.5)) size = [int(math.ceil(a + b)) for a, b in zip(offset, top.get_size())] image = pygame.Surface(size, pygame.SRCALPHA) image.blit(shadow, offset) image.blit(top, (0, 0)) return image def load_graphics(self): """ Loads all the graphical elements of the menu Will load some elements from disk, so needs to be called at least once. """ if not self.transparent: # load and scale the _background background = None if self.background_filename: background = tools.load_image(self.background_filename) # load and scale the menu borders border = None if self.draw_borders: border = tools.load_and_scale(self.borders_filename) # set the helper to draw the _background self.window = GraphicBox(border, background, self.background_color) # handle the arrow cursor image = tools.load_and_scale(self.cursor_filename) self.arrow = MenuCursor(image) def show_cursor(self): """ Show the cursor that indicates the selected object :returns: None """ if self.arrow not in self.menu_sprites: self.menu_sprites.add(self.arrow) self.trigger_cursor_update(False) self.get_selected_item().in_focus = True def hide_cursor(self): """ Hide the cursor that indicates the selected object :returns: None """ if self.arrow in self.menu_sprites: self.menu_sprites.remove(self.arrow) selected = self.get_selected_item() if selected is not None: selected.in_focus = False def refresh_layout(self): """ Fit border to contents and hide/show cursor :return: """ self.menu_items.expand = not self.shrink_to_items # check if we have items, but they are all disabled disabled = all(not i.enabled for i in self.menu_items) if self.menu_items and not disabled: self.show_cursor() else: self.hide_cursor() if self.shrink_to_items: self.fit_border() def draw(self, surface): """ Draws the menu object to a pygame surface. :param surface: Surface to draw on :type surface: pygame.Surface :rtype: None :returns: None """ if self._needs_refresh: self.refresh_layout() self._needs_refresh = False if not self.transparent: self.window.draw(surface, self.rect) if self._show_contents: self.menu_items.draw(surface) self.menu_sprites.draw(surface) self.sprites.draw(surface) # debug = show the menu items area # surface.fill((255, 0, 0), self.calc_internal_rect(), 2) def set_font(self, size=5, font=None, color=(10, 10, 10), line_spacing=10): """Set the font properties that the menu uses including font _color, size, typeface, and line spacing. The size and line_spacing parameters will be adjusted the the screen scale. You should pass the original, unscaled values. :param size: The font size in pixels. :param font: Path to the typeface file (.ttf) :param color: A tuple of the RGB _color values :param line_spacing: The spacing in pixels between lines of text :type size: Integer :type font: String :type color: Tuple :type line_spacing: Integer :rtype: None :returns: None .. image:: images/menu/set_font.png """ if font is None: font = prepare.BASEDIR + self.font_filename if size < self.min_font_size: size = self.min_font_size self.line_spacing = tools.scale(line_spacing) self.font_size = tools.scale(size) self.font_color = color self.font = pygame.font.Font(font, self.font_size) def calc_internal_rect(self): """ Calculate the area inside the borders, if any. If no borders are present, a copy of the menu rect will be returned :returns: Rect representing space inside borders, if any :rtype: pygame.Rect """ return self.window.calc_inner_rect(self.rect) def process_event(self, event): """ Process pygame input events The menu cursor is handled here, as well as the ESC and ENTER keys. This will also toggle the 'in_focus' of the menu item :param event: pygame.Event :returns: None """ if event.type == pygame.KEYDOWN: # TODO: remove this check each time # check if we have items, but they are all disabled disabled = all(not i.enabled for i in self.menu_items) if self.escape_key_exits and event.key == pygame.K_ESCAPE: self.close() return elif self.state == "normal" and not disabled and self.menu_items: if event.key == pygame.K_RETURN: self.menu_select_sound.play() self.on_menu_selection(self.get_selected_item()) else: index = self.menu_items.determine_cursor_movement(self.selected_index, event) if not self.selected_index == index: self.change_selection(index) # TODO: handling of click/drag, miss-click, etc # TODO: eventually, maybe move some handling into menuitems # TODO: handle screen scaling? # TODO: generalized widget system elif self.touch_aware and event.type == pygame.MOUSEBUTTONDOWN: # menu items is (sometimes) a relative group, so their rect will be relative to their parent # we need to adjust the point to topleft of the containing rect # eventually, a widget system could do this automatically # make sure that the rect's position is current # a sprite group may not be a relative group... so an attribute error will be raised # obvi, a wart, but will be fixed sometime (tm) try: self.menu_items.update_rect_from_parent() except AttributeError: # not a relative group, no need to adjust cursor mouse_pos = event.pos else: # move the mouse/touch origin to be relative to the menu_items # TODO: a vector type would be niceeee mouse_pos = [a - b for a, b in zip(event.pos, self.menu_items.rect.topleft)] # loop through all the items here and see if they collide # eventually, we should make this more generic...not part of the menu for index, item in enumerate([i for i in self.menu_items if i.enabled]): if item.rect.collidepoint(mouse_pos): self.change_selection(index) self.on_menu_selection(self.get_selected_item()) def change_selection(self, index, animate=True): """ Force the menu to be evaluated and move cursor and trigger focus changes :return: None """ previous = self.get_selected_item() if previous is not None: previous.in_focus = False # clear the focus flag of old item, if any self.selected_index = index # update the selection index self.menu_select_sound.play() # play a sound self.trigger_cursor_update(animate) # move cursor and [maybe] animate it self.get_selected_item().in_focus = True # set focus flag of new item self.on_menu_selection_change() # let subclass know menu has changed def search_items(self, game_object): """ Non-optimised search through menu_items for a particular thing TODO: address the confusing name "game object" :param game_object: :return: """ for menu_item in self.menu_items: if game_object == menu_item.game_object: return menu_item return None def trigger_cursor_update(self, animate=True): """ Force the menu cursor to move into the correct position :param animate: If True, then arrow will move smoothly into position :returns: None or Animation """ selected = self.get_selected_item() if not selected: return x, y = selected.rect.midleft x -= tools.scale(2) if animate: self.remove_animations_of(self.arrow.rect) return self.animate(self.arrow.rect, right=x, centery=y, duration=self.cursor_move_duration) else: self.arrow.rect.midright = x, y return None def get_selected_item(self): """ Get the Menu Item that is currently selected :rtype: MenuItem :rtype: core.components.menu.interface.MenuItem """ try: return self.menu_items[self.selected_index] except IndexError: return None def resume(self): if self.state == "closed": def show_items(): self.state = "normal" self._show_contents = True self.on_menu_selection_change() self.on_open() self.state = "opening" self.reload_items() self.refresh_layout() ani = self.animate_open() if ani: if self.animate_contents: self._show_contents = True # TODO: make some "dirty" or invalidate layout API # this will make sure items are arranged as menu opens ani.update_callback = partial(setattr, self.menu_items, "_needs_arrange", True) ani.callback = show_items else: self.state = "normal" show_items() elif self.state == "normal": self.reload_items() self.refresh_layout() self.on_menu_selection_change() def close(self): if self.state in ["normal", "opening"]: self.state = "closing" ani = self.animate_close() if ani: ani.callback = self.game.pop_state else: self.game.pop_state() def anchor(self, attribute, value): """ Set an anchor for the menu window You can pass any string value that is used in a pygame rect, for example: "center", "topleft", and "right". When changes are made to the window or it is being opened or sized, then these values passed as anchors will override others. The order of which each anchor is applied is not necessarily going to match the order they were set, as the implementation relies on a dictionary. Take care to make sure values do not overlap. :param attribute: :param value: :return: """ if value is None: del self._anchors[attribute] else: self._anchors[attribute] = value def position_rect(self): """ Reposition rect taking in account the anchors """ for attribute, value in self._anchors.items(): setattr(self.rect, attribute, value) # ============================================================================ # The following methods are designed to be monkey patched or overloaded # ============================================================================ def calc_menu_items_rect(self): """ Calculate the area inside the internal rect where items are listed :rtype: pygame.Rect """ # WARNING: hardcoded values related to menu arrow size # if menu arrow image changes, this should be adjusted cursor_margin = -tools.scale(11), -tools.scale(5) inner = self.calc_internal_rect() menu_rect = inner.inflate(*cursor_margin) menu_rect.bottomright = inner.bottomright return menu_rect def calc_final_rect(self): """ Calculate the area in the game window where menu is shown This value is the __desired__ location and size, and should not change over the lifetime of the menu. It is used to generate animations to open the menu. The rect represents the size of the menu after all items are added. :rtype: pygame.Rect """ original = self.rect.copy() # store the original rect self.refresh_layout() # arrange the menu rect = self.rect.copy() # store the final rect self.rect = original # set the original back return rect def on_open(self): """ Hook is called after opening animation has finished :return: """ pass def on_menu_selection(self, item): """ Hook for things to happen when player selects a menu option Override in subclass, if you want to :return: """ if item.enabled: item.game_object() def on_menu_selection_change(self): """ Hook for things to happen after menu selection changes Override in subclass :returns: None """ pass def animate_open(self): """ Called when menu is going to open Menu will not receive input during the animation Menu will only play this animation once Must return either an Animation or Task to attach callback Only modify state of the menu Rect Do not change important state attributes :returns: Animation or Task :rtype: core.components.animation.Animation """ return None def animate_close(self): """ Called when menu is going to open Menu will not receive input during the animation Menu will play animation only once Menu will be popped after animation finished Must return either an Animation or Task to attach callback Only modify state of the menu Rect Do not change important state attributes :returns: Animation or Task :rtype: core.components.animation.Animation """ return None
class CombatState(CombatAnimations): """ The state-menu responsible for all combat related tasks and functions. .. image:: images/combat/monster_drawing01.png General description of this class: * implements a simple state machine * various phases are executed using a queue of actions * "decision queue" is used to queue player interactions/menus * this class holds mostly logic, though some graphical functions exist * most graphical functions are contained in "CombatAnimations" class Currently, status icons are implemented as follows: each round, all status icons are destroyed status icons are created for each status on each monster obvs, not ideal, maybe someday make it better? (see transition_phase) """ background_filename = "gfx/ui/combat/battle_bg03.png" draw_borders = False escape_key_exits = False def startup(self, **kwargs): self.max_positions = 1 # TODO: make dependant on match type self.phase = None self.monsters_in_play = defaultdict(list) self._damage_map = defaultdict( set) # track damage so experience can be awarded later self._technique_cache = dict() # cache for technique animations self._decision_queue = list() # queue for monsters that need decisions self._position_queue = list( ) # queue for asking players to add a monster into play (subject to change) self._action_queue = list( ) # queue for techniques, items, and status effects self._status_icons = list() # list of sprites that are status icons self._monster_sprite_map = dict() # monster => sprite self._hp_bars = dict() # monster => hp bar self._layout = dict() # player => home areas on screen self._animation_in_progress = False # if true, delay phase change self._winner = None # when set, combat ends self._round = 0 super(CombatState, self).startup(**kwargs) self.players = list(self.players) self.show_combat_dialog() self.transition_phase("begin") self.task(partial(setattr, self, "phase", "ready"), 3) def update(self, time_delta): """ Update the combat state. State machine is checked. General operation: * determine what phase to execute * if new phase, then run transition into new one * update the new phase, or the current one """ super(CombatState, self).update(time_delta) if not self._animation_in_progress: new_phase = self.determine_phase(self.phase) if new_phase: self.phase = new_phase self.transition_phase(new_phase) self.update_phase() def draw(self, surface): super(CombatState, self).draw(surface) self.draw_hp_bars() def draw_hp_bars(self): """ Go through the HP bars and redraw them :returns: None """ for monster, hud in self.hud.items(): rect = pygame.Rect(0, 0, tools.scale(70), tools.scale(8)) rect.right = hud.image.get_width() - tools.scale(8) rect.top += tools.scale(12) self._hp_bars[monster].draw(hud.image, rect) def determine_phase(self, phase): """ Determine the next phase and set it Part of state machine Only test and set new phase. * Do not execute phase actions * Try not to modify any values * Return a phase name and phase will change * Return None and phase will not change :returns: None or String """ if phase == "ready": return "housekeeping phase" elif phase == "housekeeping phase": # this will wait for players to fill battleground positions for player in self.active_players: positions_available = self.max_positions - len( self.monsters_in_play[player]) if positions_available: return return "decision phase" elif phase == "decision phase": # assume each monster executes one action # if number of actions == monsters, then all monsters are ready if len(self._action_queue) == len(self.active_monsters): return "pre action phase" # TODO: change check so that it doesn't change state # (state is changed because check_match_status will modify _winner) # if a player runs, it will be known here self.determine_winner() if self._winner: return "ran away" elif phase == "pre action phase": return "action phase" if phase == "action phase": if not self._action_queue: return "post action phase" elif phase == "post action phase": if not self._action_queue: return "resolve match" elif phase == "ran away": return "end combat" elif phase == "has winner": return "end combat" elif phase == "resolve match": if self._winner: return "has winner" else: return "housekeeping phase" def transition_phase(self, phase): """ Change from one phase from another. Part of state machine * Will be run just -once- when phase changes. * Do not change phase. * Execute code only to change into new phase. * The phase's update will be executed -after- this :param phase: :return: """ if phase == "housekeeping phase": self._round += 1 # fill all battlefield positions, but on round 1, don't ask self.fill_battlefield_positions(ask=self._round > 1) if phase == "decision phase": self.reset_status_icons() if not self._decision_queue: for player in self.human_players: # the decision queue tracks human players who need to choose an # action self._decision_queue.extend(self.monsters_in_play[player]) for trainer in self.ai_players: for monster in self.monsters_in_play[trainer]: opponents = self.monsters_in_play[self.players[0]] action, target = monster.ai.make_decision( monster, opponents) self.enqueue_action(monster, action, target) elif phase == "action phase": self._action_queue.sort(key=attrgetter("user.speed")) # TODO: Running happens somewhere else, it should be moved here i think. # TODO: Sort other items not just healing, Swap/Run? #Create a new list for items, possibly running/swap #sort items by speed of monster applied to #remove items from action_queue and insert them into their new location precedent = [] for action in self._action_queue: if action.technique.effect == 'heal': precedent.append(action) #sort items by fastest target precedent.sort(key=attrgetter("target.speed")) for action in precedent: self._action_queue.remove(action) self._action_queue.insert(0, action) elif phase == "post action phase": # apply status effects to the monsters for monster in self.active_monsters: for technique in monster.status: self.enqueue_action(None, technique, monster) elif phase == "resolve match": self.determine_winner() elif phase == "ran away": # after 3 seconds, push a state that blocks until enter is pressed # after the state is popped, the combat state will clean up and close # if you run in PvP, you need "defeated message" self.task(partial(self.game.push_state, "WaitForInputState"), 1) self.suppress_phase_change(1) elif phase == "has winner": if self._winner: # TODO: proper match check, etc if self._winner.name == "Maple": self.alert(trans('combat_defeat')) else: self.alert(trans('combat_victory')) # after 3 seconds, push a state that blocks until enter is pressed # after the state is popped, the combat state will clean up and close self.task(partial(self.game.push_state, "WaitForInputState"), 1) self.suppress_phase_change(1) elif phase == "end combat": self.end_combat() def update_phase(self): """ Execute/update phase actions Part of state machine * Do not change phase. * Will be run each iteration phase is active. * Do not test conditions to change phase. :return: None """ if self.phase == "decision phase": # show monster action menu for human players if self._decision_queue: monster = self._decision_queue.pop() self.show_monster_action_menu(monster) elif self.phase == "action phase": self.handle_action_queue() elif self.phase == "post action phase": self.handle_action_queue() def handle_action_queue(self): """ Take one action from the queue and do it :return: None """ if self._action_queue: action = self._action_queue.pop() self.perform_action(*action) self.check_party_hp() self.task(self.animate_party_status, 3) def ask_player_for_monster(self, player): """ Open dialog to allow player to choose a TXMN to enter into play :param player: :return: """ def add(menuitem): monster = menuitem.game_object if monster.current_hp == 0: tools.open_dialog(self.game, [ trans("combat_fainted", parameters={"name": monster.name}) ]) elif monster in self.active_monsters: tools.open_dialog(self.game, [ trans("combat_isactive", parameters={"name": monster.name}) ]) msg = trans("combat_replacement_is_fainted") tools.open_dialog(self.game, [msg]) else: self.add_monster_into_play(player, monster) self.game.pop_state() state = self.game.push_state("MonsterMenuState") # must use a partial because alert relies on a text box that may not exist # until after the state hs been startup state.task(partial(state.alert, trans("combat_replacement")), 0) state.on_menu_selection = add def fill_battlefield_positions(self, ask=False): """ Check the battlefield for unfilled positions and send out monsters :param ask: bool. if True, then open dialog for human players :return: """ # TODO: let work for trainer battles humans = list(self.human_players) # TODO: integrate some values for different match types released = False for player in self.active_players: positions_available = self.max_positions - len( self.monsters_in_play[player]) if positions_available: available = get_awake_monsters(player) for i in range(positions_available): released = True if player in humans and ask: self.ask_player_for_monster(player) else: self.add_monster_into_play(player, next(available)) if released: self.suppress_phase_change() def add_monster_into_play(self, player, monster): """ :param player: :param monster: :return: """ # TODO: refactor some into the combat animations feet = list(self._layout[player]['home'][0].center) feet[1] += tools.scale(11) self.animate_monster_release_bottom(feet, monster) self.build_hud(self._layout[player]['hud'][0], monster) self.monsters_in_play[player].append(monster) # TODO: not hardcode if player is self.players[0]: self.alert( trans('combat_call_tuxemon', {"name": monster.name.upper()})) else: self.alert( trans('combat_wild_appeared', {"name": monster.name.upper()})) def reset_status_icons(self): """ Update/reset status icons for monsters TODO: caching, etc """ # remove all status icons for s in self._status_icons: self.sprites.remove(s) # add status icons for monster in self.active_monsters: for status in monster.status: if status.icon: # get the rect of the monster rect = self._monster_sprite_map[monster].rect # load the sprite and add it to the display self.load_sprite(status.icon, layer=200, center=rect.topleft) def show_combat_dialog(self): """ Create and show the area where battle messages are displayed """ # make the border and area at the bottom of the screen for messages x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w, h // 4) rect.bottomright = w, h border = tools.load_and_scale(self.borders_filename) self.dialog_box = GraphicBox(border, None, self.background_color) self.dialog_box.rect = rect self.sprites.add(self.dialog_box, layer=100) # make a text area to show messages self.text_area = TextArea(self.font, self.font_color) self.text_area.rect = self.dialog_box.calc_inner_rect( self.dialog_box.rect) self.sprites.add(self.text_area, layer=100) def show_monster_action_menu(self, monster): """ Show the main window for choosing player actions :param monster: Monster to choose an action for :type monster: core.components.monster.Monster :returns: None """ message = trans('combat_monster_choice', {"name": monster.name}) self.alert(message) x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w // 2.5, h // 4) rect.bottomright = w, h state = self.game.push_state("MainCombatMenuState", columns=2) state.monster = monster state.rect = rect def skip_phase_change(self): """ Skip phase change animations Useful if player wants to skip a battle animation """ for ani in self.animations: ani.finish() def enqueue_action(self, user, technique, target=None): """ Add some technique or status to the action queue :param user: :param technique: :param target: :returns: None """ self._action_queue.append(EnqueuedAction(user, technique, target)) def remove_monster_actions_from_queue(self, monster): """ Remove all queued actions for a particular monster This is used mainly for removing actions after monster is fainted :type monster: core.components.monster.Monster :returns: None """ to_remove = set() for action in self._action_queue: if action.user is monster or action.target is monster: to_remove.add(action) [self._action_queue.remove(action) for action in to_remove] def suppress_phase_change(self, delay=3): """ Prevent the combat phase from changing for a limited time Use this function to prevent the phase from changing. When animating elements of the phase, call this to prevent player input as well as phase changes. :param delay: :return: """ if self._animation_in_progress: logger.debug("double suppress: bug?") else: self._animation_in_progress = True self.task(partial(setattr, self, "_animation_in_progress", False), delay) def perform_action(self, user, technique, target=None): """ Do something with the thing: animated :param user: :param technique: Not a dict: a Technique or Item :param target: :returns: """ technique.advance_round() # This is the time, in seconds, that the animation takes to finish. action_time = 3.0 result = technique.use(user, target) try: tools.load_sound(technique.sfx).play() except AttributeError: pass # action is performed, so now use sprites to animate it # this value will be None if the target is off screen target_sprite = self._monster_sprite_map.get(target, None) # slightly delay the monster shake, so technique animation # is synchronized with the damage shake motion hit_delay = 0 if user: message = trans('combat_used_x', { "user": user.name, "name": technique.name }) # TODO: a real check or some params to test if should tackle, etc if result["should_tackle"]: hit_delay += .5 user_sprite = self._monster_sprite_map[user] self.animate_sprite_tackle(user_sprite) if target_sprite: self.task( partial(self.animate_sprite_take_damage, target_sprite), hit_delay + .2) self.task(partial(self.blink, target_sprite), hit_delay + .6) # Track damage self._damage_map[target].add(user) else: # assume this was an item used # handle the capture device if result["name"] == "capture": message += "\n" + trans('attempting_capture') action_time = result["num_shakes"] + 1.8 self.animate_capture_monster(result["success"], result["num_shakes"], target) if result["success"]: # end combat right here self.task(self.end_combat, action_time + 0.5) # Display 'Gotcha!' first. self.task(partial(self.alert, trans('gotcha')), action_time) self._animation_in_progress = True return # generic handling of anything else else: if result["success"]: message += "\n" + trans('item_success') else: message += "\n" + trans('item_failure') self.alert(message) self.suppress_phase_change(action_time) else: if result["success"]: self.suppress_phase_change() self.alert( trans('combat_status_damage', { "name": target.name, "status": technique.name })) if result["success"] and target_sprite and hasattr( technique, "images"): tech_sprite = self.get_technique_animation(technique) tech_sprite.rect.center = target_sprite.rect.center self.task(tech_sprite.image.play, hit_delay) self.task(partial(self.sprites.add, tech_sprite, layer=50), hit_delay) self.task(tech_sprite.kill, 3) def faint_monster(self, monster): """ Instantly make the monster faint (will be removed later) :type monster: core.components.monster.Monster :returns: None """ monster.current_hp = 0 monster.status = [faint] """ Experience is earned when the target monster is fainted. Any monsters who contributed any amount of damage will be awarded Experience is distributed evenly to all participants """ if monster in self._damage_map: # Award Experience awarded_exp = monster.total_experience / monster.level / len( self._damage_map[monster]) for winners in self._damage_map[monster]: winners.give_experience(awarded_exp) # Remove monster from damage map del self._damage_map[monster] def animate_party_status(self): """ Animate monsters that need to be fainted * Animation to remove monster is handled here TODO: check for faint status, not HP :returns: None """ for player in self.monsters_in_play.keys(): for monster in self.monsters_in_play[player]: if fainted(monster): self.alert(trans('combat_fainted', {"name": monster.name})) self.animate_monster_faint(monster) self.suppress_phase_change(3) def check_party_hp(self): """ Apply status effects, then check HP, and party status * Monsters will be removed from play here :returns: None """ for player in self.monsters_in_play.keys(): for monster in self.monsters_in_play[player]: self.animate_hp(monster) if monster.current_hp <= 0 and not fainted(monster): self.remove_monster_actions_from_queue(monster) self.faint_monster(monster) def get_technique_animation(self, technique): """ Return a sprite usable as a technique animation TODO: move to some generic animation loading thingy :type technique: core.components.technique.Technique :rtype: core.components.sprite.Sprite """ try: return self._technique_cache[technique] except KeyError: sprite = self.load_technique_animation(technique) self._technique_cache[technique] = sprite return sprite @staticmethod def load_technique_animation(technique): """ TODO: move to some generic animation loading thingy :param technique: :rtype: core.components.sprite.Sprite """ frame_time = .09 images = list() for fn in technique.images: image = tools.load_and_scale(fn) images.append((image, frame_time)) tech = PygAnimation(images, False) sprite = Sprite() sprite.image = tech sprite.rect = tech.get_rect() return sprite @property def active_players(self): """ Generator of any non-defeated players/trainers :rtype: collections.Iterable[core.components.player.Player] """ for player in self.players: if not defeated(player): yield player @property def human_players(self): for player in self.players: if player.isplayer: yield player @property def ai_players(self): for player in set(self.active_players) - set(self.human_players): yield player @property def active_monsters(self): """ List of any non-defeated monsters on battlefield :rtype: list """ return list(chain.from_iterable(self.monsters_in_play.values())) def remove_player(self, player): # TODO: non SP things self.players.remove(player) self.suppress_phase_change() self.alert(trans('combat_player_run')) def determine_winner(self): """ Determine if match should continue or not :return: """ if self._winner: return players = list(self.active_players) if len(players) == 1: self._winner = players[0] def end_combat(self): """ End the combat """ # TODO: End combat differently depending on winning or losing # clear action queue self._action_queue = list() contexts = {} event_engine = self.game.event_engine fadeout_action = namedtuple("action", ["type", "parameters"]) fadeout_action.type = "fadeout_music" fadeout_action.parameters = [1000] event_engine.actions["fadeout_music"]["method"](self.game, fadeout_action, contexts) for key in contexts: contexts[key].execute(game) # remove any menus that may be on top of the combat state while self.game.current_state is not self: self.game.pop_state() self.game.push_state("FadeOutTransition", caller=self)
class CombatTargetMenuState(Menu): """ Menu for selecting targets of techniques and items This special menu draws over the combat screen """ transparent = True def create_new_menu_items_group(self): # these groups will not automatically position the sprites self.menu_items = MenuSpriteGroup() self.menu_sprites = SpriteGroup() def startup(self, *args, **kwargs): super(CombatTargetMenuState, self).startup(*args, **kwargs) # used to determine how player can target techniques self.user = kwargs.get("user") self.action = kwargs.get("action") self.player = kwargs.get("player") # load and scale the menu borders border = tools.load_and_scale(self.borders_filename) self.border = GraphicBox(border, None, None) def initialize_items(self): # get a ref to the combat state combat_state = self.game.get_state_name("CombatState") # TODO: trainer targeting # TODO: cleanup how monster sprites and whatnot are managed # TODO: This is going to work fine for simple matches, but controls will be wonky for parties # TODO: (cont.) Need better handling of cursor keys for 2d layouts of menu items # get all the monster positions # this is used to determine who owns what monsters and what not # TODO: make less duplication of game data in memory, let combat state have more registers, etc self.targeting_map = defaultdict(list) for player, monsters in combat_state.monsters_in_play.items(): for monster in monsters: # TODO: more targeting classes if player == self.player: targeting_class = "own monster" else: targeting_class = "enemy monster" self.targeting_map[targeting_class].append(monster) # TODO: handle odd cases where a situation creates no valid targets # if this target type is not handled by this action, then skip it if targeting_class not in self.action.target: continue # inspect the monster sprite and make a border image for it sprite = combat_state._monster_sprite_map[monster] item = MenuItem(None, None, None, monster) item.rect = sprite.rect.copy() center = item.rect.center item.rect.inflate_ip(tools.scale(16), tools.scale(16)) item.rect.center = center yield item def refresh_layout(self): """ Before refreshing the layout, determine the optimal target :return: """ def determine_target(): for tag in self.action.target: for target in self.targeting_map[tag]: menu_item = self.search_items(target) if menu_item.enabled: # TODO: some API for this mess # get the index of the menu_item # change it index = self.menu_items._spritelist.index(menu_item) self.selected_index = index return determine_target() super(CombatTargetMenuState, self).refresh_layout() def on_menu_selection_change(self): """ Draw borders around sprites when selection changes :return: """ # clear out the old borders for sprite in self.menu_items: sprite.image = None # find the selected item and make a border for it item = self.get_selected_item() if item: item.image = pygame.Surface(item.rect.size, pygame.SRCALPHA) self.border.draw(item.image)
class Menu(state.State): """A class to create menu objects. :background: String :ivar rect: The rect of the menu in pixels, defaults to 0, 0, 400, 200. :ivar state: An arbitrary state of the menu. E.g. "opening" or "closing". :ivar selected_index: The index position of the currently selected menu item. :ivar menu_items: A list of available menu items. """ # defaults for the menu columns = 1 min_font_size = 4 draw_borders = True background = None background_color = 248, 248, 248 # The window's background olor background_filename = None menu_select_sound_filename = "sounds/interface/menu-select.ogg" font_filename = "resources/font/PressStart2P.ttf" borders_filename = "gfx/dialog-borders01.png" cursor_filename = "gfx/arrow.png" cursor_move_duration = .20 default_character_delay = 0.05 shrink_to_items = False escape_key_exits = True def startup(self, *items, **kwargs): self.rect = self.rect.copy() # do not remove! self.__dict__.update(kwargs) # used to position the menu/state self._anchors = dict() # holds sprites representing menu items self.menu_items = VisualSpriteList(parent=self.calc_menu_items_rect) self.menu_items.columns = self.columns if self.shrink_to_items: self.menu_items.expand = False # generally just for the cursor arrow self.menu_sprites = RelativeGroup(parent=self.menu_items) self.selected_index = 0 # Used to track which menu item is selected self.state = "closed" # closed, opening, normal, closing self.set_font() # load default font self.load_graphics() # load default graphics self.reload_sounds() # load default sounds def shutdown(self): """ Clear objects likely to cause cyclical references :returns: None """ self.sprites.empty() self.menu_items.empty() self.menu_sprites.empty() self.animations.empty() del self.arrow del self.menu_items del self.menu_sprites def start_text_animation(self, text_area): """ Start an animation to show textarea, one character at a time :param text_area: TextArea to animate :type text_area: core.components.ui.text.TextArea :rtype: None """ def next_character(): try: next(text_area) except StopIteration: pass else: self.task(next_character, self.character_delay) self.character_delay = self.default_character_delay self.remove_animations_of(next_character) next_character() def animate_text(self, text_area, text): """ Set and animate a text area :param text: Test to display :type text: basestring :param text_area: TextArea to animate :type text_area: core.components.ui.text.TextArea :rtype: None """ text_area.text = text self.start_text_animation(text_area) def alert(self, message): """ Write a message to the first available text area Generally, a state will have just one, if any, text area. The first one found will be use to display the message. If no text area is found, a RuntimeError will be raised :param message: Something interesting, I hope. :type message: basestring :returns: None """ def find_textarea(): for sprite in self.sprites: if isinstance(sprite, TextArea): return sprite print("attempted to use 'alert' on state without a TextArea", message) raise RuntimeError self.animate_text(find_textarea(), message) def _initialize_items(self): """ Internal use only. Will reset the items in the menu Reset the menu items and get new updated ones. :rtype: collections.Iterable[MenuItem] """ self.selected_index = 0 self.menu_items.empty() for item in self.initialize_items(): self.menu_items.add(item) if self.menu_items: self.show_cursor() # call item selection change to trigger callback for first time self.on_menu_selection_change() if self.shrink_to_items: center = self.rect.center rect1 = self.menu_items.calc_bounding_rect() rect2 = self.menu_sprites.calc_bounding_rect() rect1 = rect1.union(rect2) # TODO: do not hardcode these values # border is 12, padding is the rest rect1.width += tools.scale(18) rect1.height += tools.scale(19) rect1.topleft = 0, 0 # self.rect = rect1.union(rect2) # self.rect.width += tools.scale(20) # self.rect.topleft = 0, 0 self.rect = rect1 self.rect.center = center self.position_rect() def reload_sounds(self): """ Reload sounds :returns: None """ self.menu_select_sound = tools.load_sound(self.menu_select_sound_filename) def shadow_text(self, text, bg=(192, 192, 192)): """ Draw shadowed text :param text: Text to draw :param bg: :returns: """ top = self.font.render(text, 1, self.font_color) shadow = self.font.render(text, 1, bg) offset = layout((0.5, 0.5)) size = [int(math.ceil(a + b)) for a, b in zip(offset, top.get_size())] image = pygame.Surface(size, pygame.SRCALPHA) image.blit(shadow, offset) image.blit(top, (0, 0)) return image def load_graphics(self): """ Loads all the graphical elements of the menu Will load some elements from disk, so needs to be called at least once. """ # load and scale the _background background = None if self.background_filename: background = tools.load_image(self.background_filename) # load and scale the menu borders border = None if self.draw_borders: border = tools.load_and_scale(self.borders_filename) # set the helper to draw the _background self.window = GraphicBox(border, background, self.background_color) # handle the arrow cursor image = tools.load_and_scale(self.cursor_filename) self.arrow = MenuCursor(image) def show_cursor(self): """ Show the cursor that indicates the selected object :returns: None """ if self.arrow not in self.menu_sprites: self.menu_sprites.add(self.arrow) self.trigger_cursor_update(False) self.get_selected_item().in_focus = True def hide_cursor(self): """ Hide the cursor that indicates the selected object :returns: None """ self.menu_sprites.remove(self.arrow) self.get_selected_item().in_focus = False def draw(self, surface): """ Draws the menu object to a pygame surface. :param surface: Surface to draw on :type surface: pygame.Surface :rtype: None :returns: None """ self.window.draw(surface, self.rect) if self.state == "normal" and self.menu_items: self.menu_items.draw(surface) self.menu_sprites.draw(surface) self.sprites.draw(surface) # debug = show the menu items area # surface.fill((255, 0, 0), self.calc_internal_rect(), 2) def set_font(self, size=5, font=None, color=(10, 10, 10), line_spacing=10): """Set the font properties that the menu uses including font _color, size, typeface, and line spacing. The size and line_spacing parameters will be adjusted the the screen scale. You should pass the original, unscaled values. :param size: The font size in pixels. :param font: Path to the typeface file (.ttf) :param color: A tuple of the RGB _color values :param line_spacing: The spacing in pixels between lines of text :type size: Integer :type font: String :type color: Tuple :type line_spacing: Integer :rtype: None :returns: None .. image:: images/menu/set_font.png """ if font is None: font = prepare.BASEDIR + self.font_filename if size < self.min_font_size: size = self.min_font_size self.line_spacing = tools.scale(line_spacing) self.font_size = tools.scale(size) self.font_color = color self.font = pygame.font.Font(font, self.font_size) def calc_internal_rect(self): """ Calculate the area inside the borders, if any. If no borders are present, a copy of the menu rect will be returned :returns: Rect representing space inside borders, if any :rtype: pygame.Rect """ return self.window.calc_inner_rect(self.rect) def process_event(self, event): """ Process pygame input events The menu cursor is handled here, as well as the ESC and ENTER keys. This will also toggle the 'in_focus' of the menu item :param event: pygame.Event :returns: None """ if event.type == pygame.KEYDOWN: if self.escape_key_exits and event.key == pygame.K_ESCAPE: self.close() return elif self.state == "normal" and self.menu_items and event.key == pygame.K_RETURN: self.menu_select_sound.play() self.on_menu_selection(self.get_selected_item()) return # check if cursor has changed index = self.menu_items.determine_cursor_movement(self.selected_index, event) if not self.selected_index == index: self.get_selected_item().in_focus = False # clear the focus flag of old item self.selected_index = index # update the selection index self.menu_select_sound.play() self.trigger_cursor_update() # move cursor and animate it self.get_selected_item().in_focus = True # set focus flag of new item self.on_menu_selection_change() # let subclass know menu has changed def trigger_cursor_update(self, animate=True): """ Force the menu cursor to move into the correct position :param animate: If True, then arrow will move smoothly into position :returns: None or Animation """ selected = self.get_selected_item() if not selected: return x, y = selected.rect.midleft x -= tools.scale(2) if animate: self.remove_animations_of(self.arrow.rect) return self.animate(self.arrow.rect, right=x, centery=y, duration=self.cursor_move_duration) else: self.arrow.rect.midright = x, y return None def get_selected_item(self): """ Get the Menu Item that is currently selected :rtype: MenuItem :rtype: core.components.menu.interface.MenuItem """ try: return self.menu_items[self.selected_index] except IndexError: return None def resume(self): if self.state == "closed": def show_items(): self.state = "normal" self.on_open() self._initialize_items() self.state = "opening" self.position_rect() ani = self.animate_open() if ani: ani.callback = show_items else: show_items() def close(self): if self.state in ["normal", "opening"]: self.state = "closing" ani = self.animate_close() if ani: ani.callback = self.game.pop_state else: self.game.pop_state() def anchor(self, attribute, value): """ Set an anchor for the menu window You can pass any string value that is used in a pygame rect, for example: "center", "topleft", and "right. When changes are made to the window, when it is being opened or sized, then these values passed as anchors will override others. The order of which each anchor is applied is not necessarily going to match the order they were set, as the implementation relies on a dictionary. Take care to make sure values do not overlap, :param attribute: :param value: :return: """ if value is None: del self._anchors[attribute] else: self._anchors[attribute] = value def position_rect(self): """ Reposition rect taking in account the anchors """ for attribute, value in self._anchors.items(): setattr(self.rect, attribute, value) def update(self, time_delta): self.animations.update(time_delta) # ============================================================================ # The following methods are designed to be monkey patched or overloaded # ============================================================================ def calc_menu_items_rect(self): """ Calculate the area inside the internal rect where items are listed :rtype: pygame.Rect """ # WARNING: hardcoded values related to menu arrow size # if menu arrow image changes, this should be adjusted cursor_margin = -tools.scale(11), -tools.scale(5) inner = self.calc_internal_rect() menu_rect = inner.inflate(*cursor_margin) menu_rect.bottomright = inner.bottomright return menu_rect def calc_final_rect(self): """ Calculate the area in the game window where menu is shown This value is the __desired__ location, and should not change over the lifetime of the menu. It is used to generate animations to open the menu. By default, this will be the entire screen :rtype: pygame.Rect """ return self.rect def on_open(self): """ Hook is called after opening animation has finished :return: """ pass def on_menu_selection(self, item): """ Hook for things to happen when player selects a menu option Override in subclass :return: """ pass def on_menu_selection_change(self): """ Hook for things to happen after menu selection changes Override in subclass :returns: None """ pass def initialize_items(self): """ Hook for adding items to menu when menu is created Override with a generator :returns: Generator of MenuItems :rtype: collections.Iterable[MenuItem] """ return iter(()) def animate_open(self): """ Called when menu is going to open Menu will not receive input during the animation Menu will only play this animation once Must return either an Animation or Task to attach callback Only modify state of the menu Rect Do not change important state attributes :returns: Animation or Task :rtype: core.components.animation.Animation """ return None def animate_close(self): """ Called when menu is going to open Menu will not receive input during the animation Menu will play animation only once Menu will be popped after animation finished Must return either an Animation or Task to attach callback Only modify state of the menu Rect Do not change important state attributes :returns: Animation or Task :rtype: core.components.animation.Animation """ return None
def load_graphics(self): """ Image become class attribute, so is shared. Eventually, implement some game-wide image caching """ image = tools.load_and_scale(self.border_filename) HpBar.border = GraphicBox(image)
class CombatState(CombatAnimations): """ The state-menu responsible for all combat related tasks and functions. .. image:: images/combat/monster_drawing01.png General description of this class: * implements a simple state machine * various phases are executed using a queue of actions * "decision queue" is used to queue player interactions/menus * this class holds mostly logic, though some graphical functions exist * most graphical functions are contained in "CombatAnimations" class Currently, status icons are implemented as follows: each round, all status icons are destroyed status icons are created for each status on each monster obvs, not ideal, maybe someday make it better? (see transition_phase) """ background_filename = "gfx/ui/combat/battle_bg03.png" draw_borders = False escape_key_exits = False def startup(self, **kwargs): self.max_positions = 1 # TODO: make dependant on match type self.phase = None self.monsters_in_play = defaultdict(list) self._damage_map = defaultdict(set) # track damage so experience can be awarded later self._technique_cache = dict() # cache for technique animations self._decision_queue = list() # queue for monsters that need decisions self._position_queue = list() # queue for asking players to add a monster into play (subject to change) self._action_queue = list() # queue for techniques, items, and status effects self._status_icons = list() # list of sprites that are status icons self._monster_sprite_map = dict() # monster => sprite self._hp_bars = dict() # monster => hp bar self._layout = dict() # player => home areas on screen self._animation_in_progress = False # if true, delay phase change self._round = 0 super(CombatState, self).startup(**kwargs) self.players = list(self.players) self.show_combat_dialog() self.transition_phase("begin") self.task(partial(setattr, self, "phase", "ready"), 3) def update(self, time_delta): """ Update the combat state. State machine is checked. General operation: * determine what phase to update * if new phase, then run transition into new one * update the new phase, or the current one """ super(CombatState, self).update(time_delta) if not self._animation_in_progress: new_phase = self.determine_phase(self.phase) if new_phase: self.phase = new_phase self.transition_phase(new_phase) self.update_phase() def draw(self, surface): super(CombatState, self).draw(surface) self.draw_hp_bars() def draw_hp_bars(self): """ Go through the HP bars and redraw them :returns: None """ for monster, hud in self.hud.items(): rect = pygame.Rect(0, 0, tools.scale(70), tools.scale(8)) rect.right = hud.image.get_width() - tools.scale(8) rect.top += tools.scale(12) self._hp_bars[monster].draw(hud.image, rect) def determine_phase(self, phase): """ Determine the next phase and set it Part of state machine Only test and set new phase. * Do not update phase actions * Try not to modify any values * Return a phase name and phase will change * Return None and phase will not change :returns: None or String """ if phase == "ready": return "housekeeping phase" elif phase == "housekeeping phase": # this will wait for players to fill battleground positions for player in self.active_players: positions_available = self.max_positions - len(self.monsters_in_play[player]) if positions_available: return # DO NOT REMOVE THIS CODE # enable it to test for draw matches if 0: t = Technique("technique_poison_sting") for p, m in self.monsters_in_play.items(): for m in m: m.current_hp = min(m.current_hp, 1) t.use(m, m) # enable to test for defeat in matches if 0: [setattr(m, 'current_hp', 1) for m in self.players[0].monsters] return "decision phase" elif phase == "decision phase": # TODO: only works for single player and if player runs if len(self.remaining_players) == 1: return "ran away" # assume each monster executes one action # if number of actions == monsters, then all monsters are ready if len(self._action_queue) == len(self.active_monsters): return "pre action phase" elif phase == "pre action phase": return "action phase" if phase == "action phase": if not self._action_queue: return "post action phase" elif phase == "post action phase": if not self._action_queue: return "resolve match" elif phase == "resolve match": remaining = len(self.remaining_players) if remaining == 0: return "draw match" elif remaining == 1: return "has winner" else: return "housekeeping phase" elif phase == "ran away": return "end combat" elif phase == "draw match": return "end combat" elif phase == "has winner": return "end combat" def transition_phase(self, phase): """ Change from one phase from another. Part of state machine * Will be run just -once- when phase changes. * Do not change phase. * Execute code only to change into new phase. * The phase's update will be executed -after- this :param phase: :return: """ if phase == "housekeeping phase": self._round += 1 # fill all battlefield positions, but on round 1, don't ask self.fill_battlefield_positions(ask=self._round > 1) if phase == "decision phase": self.reset_status_icons() if not self._decision_queue: for player in self.human_players: # the decision queue tracks human players who need to choose an # action self._decision_queue.extend(self.monsters_in_play[player]) for trainer in self.ai_players: for monster in self.monsters_in_play[trainer]: action = self.get_combat_decision_from_ai(monster) self._action_queue.append(action) elif phase == "action phase": self.sort_action_queue() elif phase == "post action phase": # apply status effects to the monsters for monster in self.active_monsters: for technique in monster.status: self.enqueue_action(None, technique, monster) elif phase == "resolve match": pass elif phase == "ran away": self.alert(trans('combat_player_run')) # after 3 seconds, push a state that blocks until enter is pressed # after the state is popped, the combat state will clean up and close # if you run in PvP, you need "defeated message" self.task(partial(self.game.push_state, "WaitForInputState"), 2) self.suppress_phase_change(3) elif phase == "draw match": # it is a draw match; both players were defeated in same round self.alert(trans('combat_draw')) # after 3 seconds, push a state that blocks until enter is pressed # after the state is popped, the combat state will clean up and close self.task(partial(self.game.push_state, "WaitForInputState"), 2) self.suppress_phase_change(3) elif phase == "has winner": # TODO: proper match check, etc # This assumes that player[0] is the human playing in single player if self.remaining_players[0] == self.players[0]: self.alert(trans('combat_victory')) else: self.alert(trans('combat_defeat')) # after 3 seconds, push a state that blocks until enter is pressed # after the state is popped, the combat state will clean up and close self.task(partial(self.game.push_state, "WaitForInputState"), 2) self.suppress_phase_change(3) elif phase == "end combat": self.end_combat() def get_combat_decision_from_ai(self, monster): """ Get ai action from a monster and enqueue it :param monster: :param opponents: :return: """ # TODO: parties/teams/etc to choose opponents opponents = self.monsters_in_play[self.players[0]] technique, target = monster.ai.make_decision(monster, opponents) return EnqueuedAction(monster, technique, target) def sort_action_queue(self): """ Sort actions in the queue according to game rules * Swap actions are always first * Techniques that damage are sorted by monster speed * Items are sorted by trainer speed :return: """ def rank_action(action): sort = action.technique.sort try: primary_order = sort_order.index(sort) except IndexError: logger.error("unsortable action: ", action) primary_order = -1 if sort == 'meta': # all meta items sorted together # use of 0 leads to undefined sort/probably random return primary_order, 0 else: # TODO: determine the secondary sort element, monster speed, trainer speed, etc return primary_order, action.user.speed sort_order = ['meta', 'item', 'utility', 'potion', 'food', 'heal', 'damage'] # TODO: Running happens somewhere else, it should be moved here i think. # TODO: Eventually make an action queue class? self._action_queue.sort(key=rank_action, reverse=True) def update_phase(self): """ Execute/update phase actions Part of state machine * Do not change phase. * Will be run each iteration phase is active. * Do not test conditions to change phase. :return: None """ if self.phase == "decision phase": # show monster action menu for human players if self._decision_queue: monster = self._decision_queue.pop() self.show_monster_action_menu(monster) elif self.phase == "action phase": self.handle_action_queue() elif self.phase == "post action phase": self.handle_action_queue() def handle_action_queue(self): """ Take one action from the queue and do it :return: None """ if self._action_queue: action = self._action_queue.pop() self.perform_action(*action) self.check_party_hp() self.task(self.animate_party_status, 3) def ask_player_for_monster(self, player): """ Open dialog to allow player to choose a TXMN to enter into play :param player: :return: """ def add(menuitem): monster = menuitem.game_object if monster.current_hp == 0: tools.open_dialog(self.game, [trans("combat_fainted", parameters={"name": monster.name})]) elif monster in self.active_monsters: tools.open_dialog(self.game, [trans("combat_isactive", parameters={"name": monster.name})]) msg = trans("combat_replacement_is_fainted") tools.open_dialog(self.game, [msg]) else: self.add_monster_into_play(player, monster) self.game.pop_state() state = self.game.push_state("MonsterMenuState") # must use a partial because alert relies on a text box that may not exist # until after the state hs been startup state.task(partial(state.alert, trans("combat_replacement")), 0) state.on_menu_selection = add def fill_battlefield_positions(self, ask=False): """ Check the battlefield for unfilled positions and send out monsters :param ask: bool. if True, then open dialog for human players :return: """ # TODO: let work for trainer battles humans = list(self.human_players) # TODO: integrate some values for different match types released = False for player in self.active_players: positions_available = self.max_positions - len(self.monsters_in_play[player]) if positions_available: available = get_awake_monsters(player) for i in range(positions_available): released = True if player in humans and ask: self.ask_player_for_monster(player) else: self.add_monster_into_play(player, next(available)) if released: self.suppress_phase_change() def add_monster_into_play(self, player, monster): """ :param player: :param monster: :return: """ # TODO: refactor some into the combat animations feet = list(self._layout[player]['home'][0].center) feet[1] += tools.scale(11) self.animate_monster_release_bottom(feet, monster) self.build_hud(self._layout[player]['hud'][0], monster) self.monsters_in_play[player].append(monster) # TODO: not hardcode if player is self.players[0]: self.alert(trans('combat_call_tuxemon', {"name": monster.name.upper()})) else: self.alert(trans('combat_wild_appeared', {"name": monster.name.upper()})) def reset_status_icons(self): """ Update/reset status icons for monsters TODO: caching, etc """ # remove all status icons for s in self._status_icons: self.sprites.remove(s) # add status icons for monster in self.active_monsters: for status in monster.status: if status.icon: # get the rect of the monster rect = self._monster_sprite_map[monster].rect # load the sprite and add it to the display self.load_sprite(status.icon, layer=200, center=rect.topleft) def show_combat_dialog(self): """ Create and show the area where battle messages are displayed """ # make the border and area at the bottom of the screen for messages x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w, h // 4) rect.bottomright = w, h border = tools.load_and_scale(self.borders_filename) self.dialog_box = GraphicBox(border, None, self.background_color) self.dialog_box.rect = rect self.sprites.add(self.dialog_box, layer=100) # make a text area to show messages self.text_area = TextArea(self.font, self.font_color) self.text_area.rect = self.dialog_box.calc_inner_rect(self.dialog_box.rect) self.sprites.add(self.text_area, layer=100) def show_monster_action_menu(self, monster): """ Show the main window for choosing player actions :param monster: Monster to choose an action for :type monster: core.components.monster.Monster :returns: None """ message = trans('combat_monster_choice', {"name": monster.name}) self.alert(message) x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w // 2.5, h // 4) rect.bottomright = w, h state = self.game.push_state("MainCombatMenuState", columns=2) state.monster = monster state.rect = rect def skip_phase_change(self): """ Skip phase change animations Useful if player wants to skip a battle animation """ for ani in self.animations: ani.finish() def enqueue_action(self, user, technique, target=None): """ Add some technique or status to the action queue :param user: :param technique: :param target: :returns: None """ self._action_queue.append(EnqueuedAction(user, technique, target)) def rewrite_action_queue_target(self, original, new): """ Used for swapping monsters :param original: :param new: :return: """ # rewrite actions in the queue to target the new monster for index, action in enumerate(self._action_queue): if action.target is original: new_action = EnqueuedAction(action.user, action.technique, new) self._action_queue[index] = new_action def remove_monster_from_play(self, trainer, monster): """ Remove monster from play without fainting it * If another monster has targeted this monster, it can change action * Will remove actions as well * currently for 'swap' technique :param monster: :return: """ self.remove_monster_actions_from_queue(monster) self.animate_monster_faint(monster) def remove_monster_actions_from_queue(self, monster): """ Remove all queued actions for a particular monster This is used mainly for removing actions after monster is fainted :type monster: core.components.monster.Monster :returns: None """ to_remove = set() for action in self._action_queue: if action.user is monster or action.target is monster: to_remove.add(action) [self._action_queue.remove(action) for action in to_remove] def suppress_phase_change(self, delay=3.0): """ Prevent the combat phase from changing for a limited time Use this function to prevent the phase from changing. When animating elements of the phase, call this to prevent player input as well as phase changes. :param delay: :return: """ if self._animation_in_progress: logger.debug("double suppress: bug?") return self._animation_in_progress = True return self.task(partial(setattr, self, "_animation_in_progress", False), delay) def perform_action(self, user, technique, target=None): """ Do something with the thing: animated :param user: :param technique: Not a dict: a Technique or Item :param target: :returns: """ technique.advance_round() # This is the time, in seconds, that the animation takes to finish. action_time = 3.0 result = technique.use(user, target) if technique.execute_trans: context = {"user": getattr(user, "name", ''), "name": technique.name, "target": target.name} message = trans(technique.execute_trans, context) else: message = '' try: tools.load_sound(technique.sfx).play() except AttributeError: pass # action is performed, so now use sprites to animate it # this value will be None if the target is off screen target_sprite = self._monster_sprite_map.get(target, None) # slightly delay the monster shake, so technique animation # is synchronized with the damage shake motion hit_delay = 0 if user: # TODO: a real check or some params to test if should tackle, etc if result["should_tackle"]: hit_delay += .5 user_sprite = self._monster_sprite_map[user] self.animate_sprite_tackle(user_sprite) if target_sprite: self.task(partial(self.animate_sprite_take_damage, target_sprite), hit_delay + .2) self.task(partial(self.blink, target_sprite), hit_delay + .6) # TODO: track total damage # Track damage self._damage_map[target].add(user) else: # assume this was an item used # handle the capture device if result["capture"]: message += "\n" + trans('attempting_capture') action_time = result["num_shakes"] + 1.8 self.animate_capture_monster(result["success"], result["num_shakes"], target) # TODO: Don't end combat right away; only works with SP, and 1 member parties # end combat right here if result["success"]: self.task(self.end_combat, action_time + 0.5) # Display 'Gotcha!' first. self.task(partial(self.alert, trans('gotcha')), action_time) self._animation_in_progress = True return # generic handling of anything else else: msg_type = 'success_trans' if result['success'] else 'failure_trans' template = getattr(technique, msg_type) if template: message += "\n" + trans(template) self.alert(message) self.suppress_phase_change(action_time) else: if result["success"]: self.suppress_phase_change() self.alert(trans('combat_status_damage', {"name": target.name, "status": technique.name})) if result["success"] and target_sprite and hasattr(technique, "images"): tech_sprite = self.get_technique_animation(technique) tech_sprite.rect.center = target_sprite.rect.center self.task(tech_sprite.image.play, hit_delay) self.task(partial(self.sprites.add, tech_sprite, layer=50), hit_delay) self.task(tech_sprite.kill, 3) def faint_monster(self, monster): """ Instantly make the monster faint (will be removed later) :type monster: core.components.monster.Monster :returns: None """ monster.current_hp = 0 monster.status = [faint] """ Experience is earned when the target monster is fainted. Any monsters who contributed any amount of damage will be awarded Experience is distributed evenly to all participants """ if monster in self._damage_map: # Award Experience awarded_exp = monster.total_experience / monster.level / len(self._damage_map[monster]) for winners in self._damage_map[monster]: winners.give_experience(awarded_exp) # Remove monster from damage map del self._damage_map[monster] def animate_party_status(self): """ Animate monsters that need to be fainted * Animation to remove monster is handled here TODO: check for faint status, not HP :returns: None """ for player, party in self.monsters_in_play.items(): for monster in party: if fainted(monster): self.alert(trans('combat_fainted', {"name": monster.name})) self.animate_monster_faint(monster) self.suppress_phase_change(3) def check_party_hp(self): """ Apply status effects, then check HP, and party status * Monsters will be removed from play here :returns: None """ for player, party in self.monsters_in_play.items(): for monster in party: self.animate_hp(monster) if monster.current_hp <= 0 and not fainted(monster): self.remove_monster_actions_from_queue(monster) self.faint_monster(monster) def get_technique_animation(self, technique): """ Return a sprite usable as a technique animation TODO: move to some generic animation loading thingy :type technique: core.components.technique.Technique :rtype: core.components.sprite.Sprite """ try: return self._technique_cache[technique] except KeyError: sprite = self.load_technique_animation(technique) self._technique_cache[technique] = sprite return sprite @staticmethod def load_technique_animation(technique): """ TODO: move to some generic animation loading thingy :param technique: :rtype: core.components.sprite.Sprite """ frame_time = .09 images = list() for fn in technique.images: image = tools.load_and_scale(fn) images.append((image, frame_time)) tech = PygAnimation(images, False) sprite = Sprite() sprite.image = tech sprite.rect = tech.get_rect() return sprite @property def active_players(self): """ Generator of any non-defeated players/trainers :rtype: collections.Iterable[core.components.player.Player] """ for player in self.players: if not defeated(player): yield player @property def human_players(self): for player in self.players: if player.isplayer: yield player @property def ai_players(self): for player in set(self.active_players) - set(self.human_players): yield player @property def active_monsters(self): """ List of any non-defeated monsters on battlefield :rtype: list """ return list(chain.from_iterable(self.monsters_in_play.values())) @property def remaining_players(self): """ List of non-defeated players/trainers. WIP right now, this is similar to Combat.active_players, but it may change in the future. For implementing teams, this would need to be different than active_players Use to check for match winner :return: list """ # TODO: perhaps change this to remaining "parties", or "teams", instead of player/trainer return [p for p in self.players if not defeated(p)] def trigger_player_run(self, player): """ WIP. make player run from battle This is a temporary fix for now. Expected to be called by the command menu. :param player: :return: """ # TODO: non SP things del self.monsters_in_play[player] self.players.remove(player) def end_combat(self): """ End the combat """ # TODO: End combat differently depending on winning or losing # clear action queue self._action_queue = list() # fade music out self.game.event_engine.execute_action("fadeout_music", [1000]) # remove any menus that may be on top of the combat state while self.game.current_state is not self: self.game.pop_state() self.game.push_state("FadeOutTransition", caller=self)
class CombatState(CombatAnimations): """ The state-menu responsible for all combat related tasks and functions. .. image:: images/combat/monster_drawing01.png """ background_filename = "gfx/ui/combat/battle_bg03.png" draw_borders = False escape_key_exits = False def startup(self, **kwargs): self.max_positions = 1 # TODO: make dependant on match type self.phase = None self.monsters_in_play = defaultdict(list) self._experience_tracking = defaultdict(list) self._technique_cache = dict() # cache for technique animations self._decision_queue = list() # queue for monsters that need decisions self._position_queue = list( ) # queue for asking players to add a monster into play (subject to change) self._action_queue = list( ) # queue for techniques, items, and status effects self._monster_sprite_map = dict() # monster => sprite self._hp_bars = dict() # monster => hp bar self._layout = dict() # player => home areas on screen self._animation_in_progress = False # if true, delay phase change self._winner = None # when set, combat ends self._round = 0 super(CombatState, self).startup(**kwargs) self.players = list(self.players) self.show_combat_dialog() self.transition_phase("begin") self.task(partial(setattr, self, "phase", "ready"), 3) def update(self, time_delta): super(CombatState, self).update(time_delta) if not self._animation_in_progress: new_phase = self.determine_phase(self.phase) if new_phase: self.phase = new_phase self.transition_phase(new_phase) self.update_phase() def draw(self, surface): super(CombatState, self).draw(surface) self.draw_hp_bars() def draw_hp_bars(self): """ Go through the HP bars and redraw them :returns: None """ for monster, hud in self.hud.items(): rect = pygame.Rect(0, 0, tools.scale(70), tools.scale(8)) rect.right = hud.image.get_width() - tools.scale(8) rect.top += tools.scale(12) self._hp_bars[monster].draw(hud.image, rect) def determine_phase(self, phase): """ Determine the next phase and set it Only test and set new phase. Do not execute phase actions. :returns: None """ if phase == "ready": return "housekeeping phase" elif phase == "housekeeping phase": return "decision phase" elif phase == "decision phase": if len(self._action_queue) == len(list(self.active_monsters)): return "pre action phase" # if a player runs, it will be known here self.check_match_status() if self._winner: return "resolve match" elif phase == "pre action phase": return "action phase" if phase == "action phase": if not self._action_queue: return "post action phase" elif phase == "post action phase": if not self._action_queue: return "ready" elif phase == "resolve match": if not self._winner: return "housekeeping phase" def transition_phase(self, phase): """ Change from one phase from another. This will be run just once when phase changes. Do not change phase. Just runs actions for new phase. :param phase: :return: """ if phase == "housekeeping phase": self._round += 1 self.fill_battlefield_positions(ask=self._round > 1) if phase == "decision phase": if not self._decision_queue: for player in self.human_players: self._decision_queue.extend(self.monsters_in_play[player]) for trainer in self.ai_players: for monster in self.monsters_in_play[trainer]: # TODO: real ai... target = choice(self.monsters_in_play[self.players[0]]) self.enqueue_action(monster, choice(monster.moves), target) elif phase == "action phase": self._action_queue.sort(key=attrgetter("user.speed")) elif phase == "post action phase": for monster in self.active_monsters: for technique in monster.status: self.enqueue_action(None, technique, monster) elif phase == "resolve match": self.check_match_status() if self._winner: self.end_combat() def update_phase(self): """ Execute/update phase actions Do not change phase. This will be run each iteration phase is active. Do not test conditions to change phase. Only do actions. :return: None """ if self.phase == "decision phase": if self._decision_queue: monster = self._decision_queue.pop() self.show_monster_action_menu(monster) elif self.phase == "action phase": self.handle_action_queue() elif self.phase == "post action phase": self.handle_action_queue() def handle_action_queue(self): """ Take one action from the queue and do it :return: None """ if self._action_queue: action = self._action_queue.pop() self.perform_action(*action) self.check_party_hp() self.task(self.animate_party_status, 3) def ask_player_for_monster(self, player): """ Open dialog to allow player to choose a TXMN to enter into play :param player: :return: """ def add(menuitem): monster = menuitem.game_object if monster.current_hp == 0: tools.open_dialog(self.game, ["Cannot choose because is fainted"]) else: self.game.pop_state() self.add_monster_into_play(player, monster) state = self.game.push_state("MonsterMenuState") # must use a partial because alert relies on a text box that may not exist # until after the state hs been startup state.task(partial(state.alert, "Choose a replacement!"), 0) state.on_menu_selection = add def fill_battlefield_positions(self, ask=False): """ Check the battlefield for unfilled positions and send out monsters :param ask: bool. if True, then open dialog for human players :return: """ # TODO: let work for trainer battles humans = list(self.human_players) released = False for player in self.active_players: positions_available = self.max_positions - len( self.monsters_in_play[player]) if positions_available: available = get_awake_monsters(player) for i in range(positions_available): released = True if player in humans and ask: self.ask_player_for_monster(player) else: self.add_monster_into_play(player, next(available)) if released: self.suppress_phase_change() def add_monster_into_play(self, player, monster): feet = list(self._layout[player]['home'][0].center) feet[1] += tools.scale(11) self.animate_monster_release_bottom(feet, monster) self.build_hud(self._layout[player]['hud'][0], monster) self.monsters_in_play[player].append(monster) # TODO: not hardcode if player is self.players[0]: self.alert('Go %s!' % monster.name.upper()) else: self.alert('A wild %s appeared!' % monster.name.upper()) def show_combat_dialog(self): """ Create and show the area where battle messages are displayed """ # make the border and area at the bottom of the screen for messages x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w, h // 4) rect.bottomright = w, h border = tools.load_and_scale(self.borders_filename) self.dialog_box = GraphicBox(border, None, self.background_color) self.dialog_box.rect = rect self.sprites.add(self.dialog_box, layer=100) # make a text area to show messages self.text_area = TextArea(self.font, self.font_color) self.text_area.rect = self.dialog_box.calc_inner_rect( self.dialog_box.rect) self.sprites.add(self.text_area, layer=100) def show_monster_action_menu(self, monster): """ Show the main window for choosing player actions :param monster: Monster to choose an action for :type monster: core.components.monster.Monster :returns: None """ message = 'What will %s do?' % monster.name self.alert(message) x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w // 2.5, h // 4) rect.bottomright = w, h state = self.game.push_state("MainCombatMenuState", columns=2) state.monster = monster state.rect = rect def skip_phase_change(self): """ Skip phase change animations """ for ani in self.animations: ani.finish() def enqueue_action(self, user, technique, target=None): self._action_queue.append(EnqueuedAction(user, technique, target)) def remove_monster_actions_from_queue(self, monster): """ Remove all queued actions for a particular monster This is used mainly for removing actions after monster is fainted :type monster: core.components.monster.Monster :returns: None """ to_remove = set() for action in self._action_queue: if action.user is monster or action.target is monster: to_remove.add(action) [self._action_queue.remove(action) for action in to_remove] def suppress_phase_change(self, delay=3): """ Prevent the combat phase from changing for a limited time Use this function to prevent the phase from changing. When animating elements of the phase, call this to prevent player input as well as phase changes. :param delay: :return: """ if self._animation_in_progress: logger.debug("double suppress: bug?") else: self._animation_in_progress = True self.task(partial(setattr, self, "_animation_in_progress", False), delay) def perform_action(self, user, technique, target=None): """ Do something with the thing: animated :param user: :param technique: Not a dict: a Technique or Item :param target: :returns: """ result = technique.use(user, target) try: tools.load_sound(technique.sfx).play() except AttributeError: pass # action is performed, so now use sprites to animate it target_sprite = self._monster_sprite_map[target] hit_delay = 0 if user: message = "%s used %s!" % (user.name, technique.name) # TODO: a real check or some params to test if should tackle, etc if technique in user.moves: hit_delay += .5 user_sprite = self._monster_sprite_map[user] self.animate_sprite_tackle(user_sprite) self.task( partial(self.animate_sprite_take_damage, target_sprite), hit_delay + .2) self.task(partial(self.blink, target_sprite), hit_delay + .6) else: # assume this was an item used if result: message += "\nIt worked!" else: message += "\nIt failed!" self.alert(message) self.suppress_phase_change() else: if result: self.suppress_phase_change() self.alert("{0.name} took {1.name} damage!".format( target, technique)) if result and hasattr(technique, "images"): tech_sprite = self.get_technique_animation(technique) tech_sprite.rect.center = target_sprite.rect.center self.task(tech_sprite.image.play, hit_delay) self.task(partial(self.sprites.add, tech_sprite, layer=50), hit_delay) self.task(tech_sprite.kill, 3) def faint_monster(self, monster): """ Instantly make the monster faint (will be removed later) :type monster: core.components.monster.Monster :returns: None """ monster.current_hp = 0 monster.status = [faint] # TODO: award experience def animate_party_status(self): """ Animate monsters that need to be fainted * Animation to remove monster is handled here TODO: check for faint status, not HP :returns: None """ for player in self.monsters_in_play.keys(): for monster in self.monsters_in_play[player]: if fainted(monster): self.alert("{0.name} fainted!".format(monster)) self.animate_monster_faint(monster) self.suppress_phase_change(3) def check_party_hp(self): """ Apply status effects, then check HP, and party status * Monsters will be removed from play here :returns: None """ for player in self.monsters_in_play.keys(): for monster in self.monsters_in_play[player]: self.animate_hp(monster) if monster.current_hp <= 0: self.remove_monster_actions_from_queue(monster) self.faint_monster(monster) def get_technique_animation(self, technique): """ Return a sprite usable as a technique animation TODO: move to some generic animation loading thingy :type technique: core.components.monster.Technique :rtype: core.components.sprite.Sprite """ try: return self._technique_cache[technique] except KeyError: sprite = self.load_technique_animation(technique) self._technique_cache[technique] = sprite return sprite @staticmethod def load_technique_animation(technique): """ TODO: move to some generic animation loading thingy :param technique: :rtype: core.components.sprite.Sprite """ frame_time = .09 images = list() for fn in technique.images: image = tools.load_and_scale(fn) images.append((image, frame_time)) tech = PygAnimation(images, False) sprite = Sprite() sprite.image = tech sprite.rect = tech.get_rect() return sprite @property def active_players(self): """ Generator of any non-defeated players/trainers :rtype: collections.Iterable[core.components.player.Player] """ for player in self.players: if not defeated(player): yield player @property def human_players(self): # TODO: this. yield self.players[0] @property def ai_players(self): for player in set(self.active_players) - set(self.human_players): yield player @property def active_monsters(self): """ Generator of any non-defeated monsters on battlefield :rtype: collections.Iterable[core.components.monster.Monster] """ for monsters in self.monsters_in_play.values(): for monster in monsters: yield monster def remove_player(self, player): # TODO: non SP things self.players.remove(player) self.suppress_phase_change() self.alert("You have run away!") def check_match_status(self): """ Determine if match should continue or not :return: """ if self._winner: return players = list(self.active_players) if len(players) == 1: self._winner = players[0] # TODO: proper match check, etc if self._winner.name == "Maple": self.alert("You've been defeated!") else: self.alert("You have won!") def end_combat(self): """ End the combat """ # TODO: End combat differently depending on winning or losing # clear action queue self._action_queue = list() event_engine = self.game.event_engine fadeout_action = namedtuple("action", ["type", "parameters"]) fadeout_action.type = "fadeout_music" fadeout_action.parameters = [1000] event_engine.actions["fadeout_music"]["method"](self.game, fadeout_action) # remove any menus that may be on top of the combat state while self.game.current_state is not self: self.game.pop_state() self.game.push_state("FadeOutTransition", caller=self)
class CombatState(CombatAnimations): """ The state-menu responsible for all combat related tasks and functions. .. image:: images/combat/monster_drawing01.png """ background_filename = "gfx/ui/combat/battle_bg03.png" draw_borders = False escape_key_exits = False def startup(self, **kwargs): self.max_positions = 1 # TODO: make dependant on match type self.phase = None self.monsters_in_play = defaultdict(list) self._experience_tracking = defaultdict(list) self._technique_cache = dict() # cache for technique animations self._decision_queue = list() # queue for monsters that need decisions self._position_queue = list() # queue for asking players to add a monster into play (subject to change) self._action_queue = list() # queue for techniques, items, and status effects self._monster_sprite_map = dict() # monster => sprite self._hp_bars = dict() # monster => hp bar self._layout = dict() # player => home areas on screen self._animation_in_progress = False # if true, delay phase change self._winner = None # when set, combat ends self._round = 0 super(CombatState, self).startup(**kwargs) self.players = list(self.players) self.show_combat_dialog() self.transition_phase("begin") self.task(partial(setattr, self, "phase", "ready"), 3) def update(self, time_delta): super(CombatState, self).update(time_delta) if not self._animation_in_progress: new_phase = self.determine_phase(self.phase) if new_phase: self.phase = new_phase self.transition_phase(new_phase) self.update_phase() def draw(self, surface): super(CombatState, self).draw(surface) self.draw_hp_bars() def draw_hp_bars(self): """ Go through the HP bars and redraw them :returns: None """ for monster, hud in self.hud.items(): rect = pygame.Rect(0, 0, tools.scale(70), tools.scale(8)) rect.right = hud.image.get_width() - tools.scale(8) rect.top += tools.scale(12) self._hp_bars[monster].draw(hud.image, rect) def determine_phase(self, phase): """ Determine the next phase and set it Only test and set new phase. Do not execute phase actions. :returns: None """ if phase == "ready": return "housekeeping phase" elif phase == "housekeeping phase": return "decision phase" elif phase == "decision phase": if len(self._action_queue) == len(list(self.active_monsters)): return "pre action phase" # if a player runs, it will be known here self.check_match_status() if self._winner: return "resolve match" elif phase == "pre action phase": return "action phase" if phase == "action phase": if not self._action_queue: return "post action phase" elif phase == "post action phase": if not self._action_queue: return "ready" elif phase == "resolve match": if not self._winner: return "housekeeping phase" def transition_phase(self, phase): """ Change from one phase from another. This will be run just once when phase changes. Do not change phase. Just runs actions for new phase. :param phase: :return: """ if phase == "housekeeping phase": self._round += 1 self.fill_battlefield_positions(ask=self._round > 1) if phase == "decision phase": if not self._decision_queue: for player in self.human_players: self._decision_queue.extend(self.monsters_in_play[player]) for trainer in self.ai_players: for monster in self.monsters_in_play[trainer]: # TODO: real ai... target = choice(self.monsters_in_play[self.players[0]]) self.enqueue_action(monster, choice(monster.moves), target) elif phase == "action phase": self._action_queue.sort(key=attrgetter("user.speed")) elif phase == "post action phase": for monster in self.active_monsters: for technique in monster.status: self.enqueue_action(None, technique, monster) elif phase == "resolve match": self.check_match_status() if self._winner: self.end_combat() def update_phase(self): """ Execute/update phase actions Do not change phase. This will be run each iteration phase is active. Do not test conditions to change phase. Only do actions. :return: None """ if self.phase == "decision phase": if self._decision_queue: monster = self._decision_queue.pop() self.show_monster_action_menu(monster) elif self.phase == "action phase": self.handle_action_queue() elif self.phase == "post action phase": self.handle_action_queue() def handle_action_queue(self): """ Take one action from the queue and do it :return: None """ if self._action_queue: action = self._action_queue.pop() self.perform_action(*action) self.check_party_hp() self.task(self.animate_party_status, 3) def ask_player_for_monster(self, player): """ Open dialog to allow player to choose a TXMN to enter into play :param player: :return: """ def add(menuitem): monster = menuitem.game_object if monster.current_hp == 0: tools.open_dialog(self.game, ["Cannot choose because is fainted"]) else: self.game.pop_state() self.add_monster_into_play(player, monster) state = self.game.push_state("MonsterMenuState") # must use a partial because alert relies on a text box that may not exist # until after the state hs been startup state.task(partial(state.alert, "Choose a replacement!"), 0) state.on_menu_selection = add def fill_battlefield_positions(self, ask=False): """ Check the battlefield for unfilled positions and send out monsters :param ask: bool. if True, then open dialog for human players :return: """ # TODO: let work for trainer battles humans = list(self.human_players) released = False for player in self.active_players: positions_available = self.max_positions - len(self.monsters_in_play[player]) if positions_available: available = get_awake_monsters(player) for i in range(positions_available): released = True if player in humans and ask: self.ask_player_for_monster(player) else: self.add_monster_into_play(player, next(available)) if released: self.suppress_phase_change() def add_monster_into_play(self, player, monster): feet = list(self._layout[player]["home"][0].center) feet[1] += tools.scale(11) self.animate_monster_release_bottom(feet, monster) self.build_hud(self._layout[player]["hud"][0], monster) self.monsters_in_play[player].append(monster) # TODO: not hardcode if player is self.players[0]: self.alert("Go %s!" % monster.name.upper()) else: self.alert("A wild %s appeared!" % monster.name.upper()) def show_combat_dialog(self): """ Create and show the area where battle messages are displayed """ # make the border and area at the bottom of the screen for messages x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w, h // 4) rect.bottomright = w, h border = tools.load_and_scale(self.borders_filename) self.dialog_box = GraphicBox(border, None, self.background_color) self.dialog_box.rect = rect self.sprites.add(self.dialog_box, layer=100) # make a text area to show messages self.text_area = TextArea(self.font, self.font_color) self.text_area.rect = self.dialog_box.calc_inner_rect(self.dialog_box.rect) self.sprites.add(self.text_area, layer=100) def show_monster_action_menu(self, monster): """ Show the main window for choosing player actions :param monster: Monster to choose an action for :type monster: core.components.monster.Monster :returns: None """ message = "What will %s do?" % monster.name self.alert(message) x, y, w, h = self.game.screen.get_rect() rect = pygame.Rect(0, 0, w // 2.5, h // 4) rect.bottomright = w, h state = self.game.push_state("MainCombatMenuState", columns=2) state.monster = monster state.rect = rect def skip_phase_change(self): """ Skip phase change animations """ for ani in self.animations: ani.finish() def enqueue_action(self, user, technique, target=None): self._action_queue.append(EnqueuedAction(user, technique, target)) def remove_monster_actions_from_queue(self, monster): """ Remove all queued actions for a particular monster This is used mainly for removing actions after monster is fainted :type monster: core.components.monster.Monster :returns: None """ to_remove = set() for action in self._action_queue: if action.user is monster or action.target is monster: to_remove.add(action) [self._action_queue.remove(action) for action in to_remove] def suppress_phase_change(self, delay=3): """ Prevent the combat phase from changing for a limited time Use this function to prevent the phase from changing. When animating elements of the phase, call this to prevent player input as well as phase changes. :param delay: :return: """ if self._animation_in_progress: logger.debug("double suppress: bug?") else: self._animation_in_progress = True self.task(partial(setattr, self, "_animation_in_progress", False), delay) def perform_action(self, user, technique, target=None): """ Do something with the thing: animated :param user: :param technique: Not a dict: a Technique or Item :param target: :returns: """ result = technique.use(user, target) try: tools.load_sound(technique.sfx).play() except AttributeError: pass # action is performed, so now use sprites to animate it target_sprite = self._monster_sprite_map[target] hit_delay = 0 if user: message = "%s used %s!" % (user.name, technique.name) # TODO: a real check or some params to test if should tackle, etc if technique in user.moves: hit_delay += 0.5 user_sprite = self._monster_sprite_map[user] self.animate_sprite_tackle(user_sprite) self.task(partial(self.animate_sprite_take_damage, target_sprite), hit_delay + 0.2) self.task(partial(self.blink, target_sprite), hit_delay + 0.6) else: # assume this was an item used if result: message += "\nIt worked!" else: message += "\nIt failed!" self.alert(message) self.suppress_phase_change() else: if result: self.suppress_phase_change() self.alert("{0.name} took {1.name} damage!".format(target, technique)) if result and hasattr(technique, "images"): tech_sprite = self.get_technique_animation(technique) tech_sprite.rect.center = target_sprite.rect.center self.task(tech_sprite.image.play, hit_delay) self.task(partial(self.sprites.add, tech_sprite, layer=50), hit_delay) self.task(tech_sprite.kill, 3) def faint_monster(self, monster): """ Instantly make the monster faint (will be removed later) :type monster: core.components.monster.Monster :returns: None """ monster.current_hp = 0 monster.status = [faint] # TODO: award experience def animate_party_status(self): """ Animate monsters that need to be fainted * Animation to remove monster is handled here TODO: check for faint status, not HP :returns: None """ for player in self.monsters_in_play.keys(): for monster in self.monsters_in_play[player]: if fainted(monster): self.alert("{0.name} fainted!".format(monster)) self.animate_monster_faint(monster) self.suppress_phase_change(3) def check_party_hp(self): """ Apply status effects, then check HP, and party status * Monsters will be removed from play here :returns: None """ for player in self.monsters_in_play.keys(): for monster in self.monsters_in_play[player]: self.animate_hp(monster) if monster.current_hp <= 0: self.remove_monster_actions_from_queue(monster) self.faint_monster(monster) def get_technique_animation(self, technique): """ Return a sprite usable as a technique animation TODO: move to some generic animation loading thingy :type technique: core.components.monster.Technique :rtype: core.components.sprite.Sprite """ try: return self._technique_cache[technique] except KeyError: sprite = self.load_technique_animation(technique) self._technique_cache[technique] = sprite return sprite @staticmethod def load_technique_animation(technique): """ TODO: move to some generic animation loading thingy :param technique: :rtype: core.components.sprite.Sprite """ frame_time = 0.09 images = list() for fn in technique.images: image = tools.load_and_scale(fn) images.append((image, frame_time)) tech = PygAnimation(images, False) sprite = Sprite() sprite.image = tech sprite.rect = tech.get_rect() return sprite @property def active_players(self): """ Generator of any non-defeated players/trainers :rtype: collections.Iterable[core.components.player.Player] """ for player in self.players: if not defeated(player): yield player @property def human_players(self): # TODO: this. yield self.players[0] @property def ai_players(self): for player in set(self.active_players) - set(self.human_players): yield player @property def active_monsters(self): """ Generator of any non-defeated monsters on battlefield :rtype: collections.Iterable[core.components.monster.Monster] """ for monsters in self.monsters_in_play.values(): for monster in monsters: yield monster def remove_player(self, player): # TODO: non SP things self.players.remove(player) self.suppress_phase_change() self.alert("You have run away!") def check_match_status(self): """ Determine if match should continue or not :return: """ if self._winner: return players = list(self.active_players) if len(players) == 1: self._winner = players[0] # TODO: proper match check, etc if self._winner.name == "Maple": self.alert("You've been defeated!") else: self.alert("You have won!") def end_combat(self): """ End the combat """ # TODO: End combat differently depending on winning or losing # clear action queue self._action_queue = list() event_engine = self.game.event_engine fadeout_action = namedtuple("action", ["type", "parameters"]) fadeout_action.type = "fadeout_music" fadeout_action.parameters = [1000] event_engine.actions["fadeout_music"]["method"](self.game, fadeout_action) # remove any menus that may be on top of the combat state while self.game.current_state is not self: self.game.pop_state() self.game.push_state("FadeOutTransition", caller=self)
class Menu(state.State): """A class to create menu objects. :background: String :ivar rect: The rect of the menu in pixels, defaults to 0, 0, 400, 200. :ivar state: An arbitrary state of the menu. E.g. "opening" or "closing". :ivar selected_index: The index position of the currently selected menu item. :ivar menu_items: A list of available menu items. """ # defaults for the menu columns = 1 min_font_size = 4 draw_borders = True background = None background_color = 248, 248, 248 # The window's background olor background_filename = None menu_select_sound_filename = "sounds/interface/menu-select.ogg" font_filename = "resources/font/PressStart2P.ttf" borders_filename = "gfx/dialog-borders01.png" cursor_filename = "gfx/arrow.png" cursor_move_duration = .20 default_character_delay = 0.05 shrink_to_items = False escape_key_exits = True def startup(self, *items, **kwargs): self.rect = self.rect.copy() # do not remove! self.__dict__.update(kwargs) # used to position the menu/state self._anchors = dict() # holds sprites representing menu items self.menu_items = VisualSpriteList(parent=self.calc_menu_items_rect) self.menu_items.columns = self.columns if self.shrink_to_items: self.menu_items.expand = False # generally just for the cursor arrow self.menu_sprites = RelativeGroup(parent=self.menu_items) self.selected_index = 0 # Used to track which menu item is selected self.state = "closed" # closed, opening, normal, closing self.set_font() # load default font self.load_graphics() # load default graphics self.reload_sounds() # load default sounds def shutdown(self): """ Clear objects likely to cause cyclical references :returns: None """ self.sprites.empty() self.menu_items.empty() self.menu_sprites.empty() self.animations.empty() del self.arrow del self.menu_items del self.menu_sprites def start_text_animation(self, text_area): """ Start an animation to show textarea, one character at a time :param text_area: TextArea to animate :type text_area: core.components.ui.text.TextArea :rtype: None """ def next_character(): try: next(text_area) except StopIteration: pass else: self.task(next_character, self.character_delay) self.character_delay = self.default_character_delay self.remove_animations_of(next_character) next_character() def animate_text(self, text_area, text): """ Set and animate a text area :param text: Test to display :type text: basestring :param text_area: TextArea to animate :type text_area: core.components.ui.text.TextArea :rtype: None """ text_area.text = text self.start_text_animation(text_area) def alert(self, message): """ Write a message to the first available text area Generally, a state will have just one, if any, text area. The first one found will be use to display the message. If no text area is found, a RuntimeError will be raised :param message: Something interesting, I hope. :type message: basestring :returns: None """ def find_textarea(): for sprite in self.sprites: if isinstance(sprite, TextArea): return sprite print("attempted to use 'alert' on state without a TextArea", message) raise RuntimeError self.animate_text(find_textarea(), message) def _initialize_items(self): """ Internal use only. Will reset the items in the menu Reset the menu items and get new updated ones. :rtype: collections.Iterable[MenuItem] """ self.selected_index = 0 self.menu_items.empty() for item in self.initialize_items(): self.menu_items.add(item) if self.menu_items: self.show_cursor() # call item selection change to trigger callback for first time self.on_menu_selection_change() if self.shrink_to_items: center = self.rect.center rect1 = self.menu_items.calc_bounding_rect() rect2 = self.menu_sprites.calc_bounding_rect() rect1 = rect1.union(rect2) # TODO: do not hardcode these values # border is 12, padding is the rest rect1.width += tools.scale(18) rect1.height += tools.scale(19) rect1.topleft = 0, 0 # self.rect = rect1.union(rect2) # self.rect.width += tools.scale(20) # self.rect.topleft = 0, 0 self.rect = rect1 self.rect.center = center self.position_rect() def reload_sounds(self): """ Reload sounds :returns: None """ self.menu_select_sound = tools.load_sound( self.menu_select_sound_filename) def shadow_text(self, text, bg=(192, 192, 192)): """ Draw shadowed text :param text: Text to draw :param bg: :returns: """ top = self.font.render(text, 1, self.font_color) shadow = self.font.render(text, 1, bg) offset = layout((0.5, 0.5)) size = [int(math.ceil(a + b)) for a, b in zip(offset, top.get_size())] image = pygame.Surface(size, pygame.SRCALPHA) image.blit(shadow, offset) image.blit(top, (0, 0)) return image def load_graphics(self): """ Loads all the graphical elements of the menu Will load some elements from disk, so needs to be called at least once. """ # load and scale the _background background = None if self.background_filename: background = tools.load_image(self.background_filename) # load and scale the menu borders border = None if self.draw_borders: border = tools.load_and_scale(self.borders_filename) # set the helper to draw the _background self.window = GraphicBox(border, background, self.background_color) # handle the arrow cursor image = tools.load_and_scale(self.cursor_filename) self.arrow = MenuCursor(image) def show_cursor(self): """ Show the cursor that indicates the selected object :returns: None """ if self.arrow not in self.menu_sprites: self.menu_sprites.add(self.arrow) self.trigger_cursor_update(False) self.get_selected_item().in_focus = True def hide_cursor(self): """ Hide the cursor that indicates the selected object :returns: None """ self.menu_sprites.remove(self.arrow) self.get_selected_item().in_focus = False def draw(self, surface): """ Draws the menu object to a pygame surface. :param surface: Surface to draw on :type surface: pygame.Surface :rtype: None :returns: None """ self.window.draw(surface, self.rect) if self.state == "normal" and self.menu_items: self.menu_items.draw(surface) self.menu_sprites.draw(surface) self.sprites.draw(surface) # debug = show the menu items area # surface.fill((255, 0, 0), self.calc_internal_rect(), 2) def set_font(self, size=5, font=None, color=(10, 10, 10), line_spacing=10): """Set the font properties that the menu uses including font _color, size, typeface, and line spacing. The size and line_spacing parameters will be adjusted the the screen scale. You should pass the original, unscaled values. :param size: The font size in pixels. :param font: Path to the typeface file (.ttf) :param color: A tuple of the RGB _color values :param line_spacing: The spacing in pixels between lines of text :type size: Integer :type font: String :type color: Tuple :type line_spacing: Integer :rtype: None :returns: None .. image:: images/menu/set_font.png """ if font is None: font = prepare.BASEDIR + self.font_filename if size < self.min_font_size: size = self.min_font_size self.line_spacing = tools.scale(line_spacing) self.font_size = tools.scale(size) self.font_color = color self.font = pygame.font.Font(font, self.font_size) def calc_internal_rect(self): """ Calculate the area inside the borders, if any. If no borders are present, a copy of the menu rect will be returned :returns: Rect representing space inside borders, if any :rtype: pygame.Rect """ return self.window.calc_inner_rect(self.rect) def process_event(self, event): """ Process pygame input events The menu cursor is handled here, as well as the ESC and ENTER keys. This will also toggle the 'in_focus' of the menu item :param event: pygame.Event :returns: None """ if event.type == pygame.KEYDOWN: if self.escape_key_exits and event.key == pygame.K_ESCAPE: self.close() return elif self.state == "normal" and self.menu_items and event.key == pygame.K_RETURN: self.menu_select_sound.play() self.on_menu_selection(self.get_selected_item()) return # check if cursor has changed index = self.menu_items.determine_cursor_movement( self.selected_index, event) if not self.selected_index == index: self.get_selected_item( ).in_focus = False # clear the focus flag of old item self.selected_index = index # update the selection index self.menu_select_sound.play() self.trigger_cursor_update() # move cursor and animate it self.get_selected_item( ).in_focus = True # set focus flag of new item self.on_menu_selection_change( ) # let subclass know menu has changed def trigger_cursor_update(self, animate=True): """ Force the menu cursor to move into the correct position :param animate: If True, then arrow will move smoothly into position :returns: None or Animation """ selected = self.get_selected_item() if not selected: return x, y = selected.rect.midleft x -= tools.scale(2) if animate: self.remove_animations_of(self.arrow.rect) return self.animate(self.arrow.rect, right=x, centery=y, duration=self.cursor_move_duration) else: self.arrow.rect.midright = x, y return None def get_selected_item(self): """ Get the Menu Item that is currently selected :rtype: MenuItem :rtype: core.components.menu.interface.MenuItem """ try: return self.menu_items[self.selected_index] except IndexError: return None def resume(self): if self.state == "closed": def show_items(): self.state = "normal" self.on_open() self._initialize_items() self.state = "opening" self.position_rect() ani = self.animate_open() if ani: ani.callback = show_items else: show_items() def close(self): if self.state in ["normal", "opening"]: self.state = "closing" ani = self.animate_close() if ani: ani.callback = self.game.pop_state else: self.game.pop_state() def anchor(self, attribute, value): """ Set an anchor for the menu window You can pass any string value that is used in a pygame rect, for example: "center", "topleft", and "right. When changes are made to the window, when it is being opened or sized, then these values passed as anchors will override others. The order of which each anchor is applied is not necessarily going to match the order they were set, as the implementation relies on a dictionary. Take care to make sure values do not overlap, :param attribute: :param value: :return: """ if value is None: del self._anchors[attribute] else: self._anchors[attribute] = value def position_rect(self): """ Reposition rect taking in account the anchors """ for attribute, value in self._anchors.items(): setattr(self.rect, attribute, value) def update(self, time_delta): self.animations.update(time_delta) # ============================================================================ # The following methods are designed to be monkey patched or overloaded # ============================================================================ def calc_menu_items_rect(self): """ Calculate the area inside the internal rect where items are listed :rtype: pygame.Rect """ # WARNING: hardcoded values related to menu arrow size # if menu arrow image changes, this should be adjusted cursor_margin = -tools.scale(11), -tools.scale(5) inner = self.calc_internal_rect() menu_rect = inner.inflate(*cursor_margin) menu_rect.bottomright = inner.bottomright return menu_rect def calc_final_rect(self): """ Calculate the area in the game window where menu is shown This value is the __desired__ location, and should not change over the lifetime of the menu. It is used to generate animations to open the menu. By default, this will be the entire screen :rtype: pygame.Rect """ return self.rect def on_open(self): """ Hook is called after opening animation has finished :return: """ pass def on_menu_selection(self, item): """ Hook for things to happen when player selects a menu option Override in subclass :return: """ pass def on_menu_selection_change(self): """ Hook for things to happen after menu selection changes Override in subclass :returns: None """ pass def initialize_items(self): """ Hook for adding items to menu when menu is created Override with a generator :returns: Generator of MenuItems :rtype: collections.Iterable[MenuItem] """ return iter(()) def animate_open(self): """ Called when menu is going to open Menu will not receive input during the animation Menu will only play this animation once Must return either an Animation or Task to attach callback Only modify state of the menu Rect Do not change important state attributes :returns: Animation or Task :rtype: core.components.animation.Animation """ return None def animate_close(self): """ Called when menu is going to open Menu will not receive input during the animation Menu will play animation only once Menu will be popped after animation finished Must return either an Animation or Task to attach callback Only modify state of the menu Rect Do not change important state attributes :returns: Animation or Task :rtype: core.components.animation.Animation """ return None