def startup(self, *args, **kwargs): super().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 = graphics.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 = 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() self.exp_bar = ExpBar() # 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 = graphics.load_and_scale(filename) filename = root + border_type + "_monster_slot_bg.png" background = graphics.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 local_session.player.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.client.screen.get_rect() rect = Rect(0, 0, w, h // 4) rect.bottomright = w, h border = graphics.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. """ if not self.transparent: # load and scale the _background background = None if self.background_filename: background = graphics.load_image(self.background_filename) # load and scale the menu borders border = None if self.draw_borders: border = graphics.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 = graphics.load_and_scale(self.cursor_filename) self.arrow = MenuCursor(image)
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().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 = graphics.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.client.get_state_by_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().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. 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 unavailable_color = 220, 220, 220 # Font color when the action is unavailable background_filename = None # File to load for image background menu_select_sound_filename = "sound_menu_select" font_filename = prepare.fetch("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 = True # 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! i = kwargs.get('selected_index', 0) self.selected_index = i # 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() self.client.release_controls() del self.arrow del self.menu_items del self.menu_sprites def start_text_animation(self, text_area, callback): """ Start an animation to show textarea, one character at a time :param text_area: TextArea to animate :type text_area: tuxemon.core.ui.text.TextArea :param callback: called when alert is complete :type callback: callable :rtype: None """ def next_character(): try: next(text_area) except StopIteration: if callback: callback() 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, callback): """ Set and animate a text area :param text: Test to display :type text: basestring :param text_area: TextArea to animate :type text_area: tuxemon.core.ui.text.TextArea :param callback: called when alert is complete :type callback: callable :rtype: None """ text_area.text = text self.start_text_animation(text_area, callback) def alert(self, message, callback=None): """ 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 :param callback: called when alert is complete :type callback: callable :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, callback) 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 is_valid_entry(self, game_object): """ Checked when items are loaded/reloaded. The return value will enable/disable menu items WIP. The value passed should be Item.game_object :param Any game_object: Any object to check :return boolean: Becomes the menu item enabled value """ return True 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) item.enabled = self.is_valid_entry(item.game_object) if hasattr(self.menu_items, "arrange_menu_items"): self.menu_items.arrange_menu_items() for index, item in enumerate(self.menu_items): if item.enabled: break self.selected_index = index 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: tuxemon.core.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 = audio.load_sound( self.menu_select_sound_filename) def shadow_text(self, text, bg=(192, 192, 192), fg=None): """ Draw shadowed text :param text: Text to draw :param bg: :returns: """ color = fg if not color: color = self.font_color top = self.font.render(text, 1, 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 = graphics.load_image(self.background_filename) # load and scale the menu borders border = None if self.draw_borders: border = graphics.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 = graphics.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 = self.font_filename if size < self.min_font_size: size = self.min_font_size self.line_spacing = tools.scale(line_spacing) if prepare.CONFIG.large_gui: self.font_size = tools.scale(size + 1) else: 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: Rect """ return self.window.calc_inner_rect(self.rect) def process_event(self, event): """ Handles player input events. This function is only called when the player provides input such as pressing a key or clicking the mouse. Since this is part of a chain of event handlers, the return value from this method becomes input for the next one. Returning None signifies that this method has dealt with an event and wants it exclusively. Return the event and others can use it as well. You should return None if you have handled input here. :type event: tuxemon.core.input.PlayerInput :rtype: Optional[core.input.PlayerInput] """ handled_event = False # close menu if event.button in (buttons.B, buttons.BACK, intentions.MENU_CANCEL): handled_event = True if event.pressed and self.escape_key_exits: self.close() disabled = True if hasattr(self, "menu_items") and event.pressed: disabled = all(not i.enabled for i in self.menu_items) valid_change = event.pressed and self.state == "normal" and not disabled and self.menu_items # confirm selection if event.button in (buttons.A, intentions.SELECT): handled_event = True if valid_change: self.menu_select_sound.play() self.on_menu_selection(self.get_selected_item()) # cursor movement if event.button in (buttons.UP, buttons.DOWN, buttons.LEFT, buttons.RIGHT): handled_event = True if valid_change: index = self.menu_items.determine_cursor_movement( self.selected_index, event) if not self.selected_index == index: self.change_selection(index) # mouse/touch selection if event.button in (buttons.MOUSELEFT, ): handled_event = True # TODO: handling of click/drag, miss-click, etc # TODO: eventually, maybe move some handling into menuitems # TODO: handle screen scaling? # TODO: generalized widget system if self.touch_aware and valid_change: mouse_pos = event.value assert mouse_pos is not 0 try: self.menu_items.update_rect_from_parent() except AttributeError: pass else: mouse_pos = [ a - b for a, b in zip(mouse_pos, self.menu_items.rect.topleft) ] 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()) if not handled_event: return event 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: tuxemon.core.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() def close(self): if self.state in ["normal", "opening"]: self.state = "closing" ani = self.animate_close() if ani: ani.callback = self.client.pop_state else: self.client.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: 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: 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: tuxemon.core.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: tuxemon.core.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._exp_bars = dict() # monster => exp 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.is_trainer_battle = kwargs.get('combat_type') == "trainer" self.players = list(self.players) self.graphics = kwargs.get('graphics') 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() self.draw_exp_bars() def draw_hp_bars(self): """ Go through the HP bars and redraw them :returns: None """ for monster, hud in self.hud.items(): rect = 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 draw_exp_bars(self): """ Go through the EXP bars and redraw them :returns: None """ for monster, hud in self.hud.items(): if hud.player: rect = Rect(0, 0, tools.scale(70), tools.scale(6)) rect.right = hud.image.get_width() - tools.scale(8) rect.top += tools.scale(31) self._exp_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 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) # record the useful properties of the last monster we fought monster_record = self.monsters_in_play[self.players[1]][0] if monster_record in self.active_monsters: self.players[0].game_variables[ 'battle_last_monster_name'] = monster_record.name self.players[0].game_variables[ 'battle_last_monster_level'] = monster_record.level self.players[0].game_variables[ 'battle_last_monster_type'] = monster_record.slug self.players[0].game_variables[ 'battle_last_monster_category'] = monster_record.category self.players[0].game_variables[ 'battle_last_monster_shape'] = monster_record.shape 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.players[0].set_party_status() self.players[0].game_variables['battle_last_result'] = 'ran' self.alert(T.translate('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.client.push_state, "WaitForInputState"), 2) self.suppress_phase_change(3) elif phase == "draw match": self.players[0].set_party_status() self.players[0].game_variables['battle_last_result'] = 'draw' # it is a draw match; both players were defeated in same round self.alert(T.translate('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.client.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 self.players[0].set_party_status() if self.remaining_players[0] == self.players[0]: self.players[0].game_variables['battle_last_result'] = 'won' self.alert(T.translate('combat_victory')) else: self.players[0].game_variables['battle_last_result'] = 'lost' self.players[0].game_variables['battle_lost_faint'] = 'true' self.alert(T.translate('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.client.push_state, "WaitForInputState"), 2) self.suppress_phase_change(3) elif phase == "end combat": self.players[0].set_party_status() 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_test(action) 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() for tech in monster.moves: tech.recharge() 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(local_session, [ T.format("combat_fainted", parameters={"name": monster.name}) ]) elif monster in self.active_monsters: tools.open_dialog(local_session, [ T.format("combat_isactive", parameters={"name": monster.name}) ]) msg = T.translate("combat_replacement_is_fainted") tools.open_dialog(local_session, [msg]) else: self.add_monster_into_play(player, monster) self.client.pop_state() state = self.client.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, T.translate("combat_replacement")), 0) state.on_menu_selection = add state.escape_key_exits = False 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 self.animate_monster_release(player, 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( T.format('combat_call_tuxemon', {"name": monster.name.upper()})) elif self.is_trainer_battle: self.alert( T.format('combat_opponent_call_tuxemon', { "name": monster.name.upper(), "user": player.name.upper(), })) else: self.alert( T.format('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.client.screen.get_rect() rect = Rect(0, 0, w, h // 4) rect.bottomright = w, h border = graphics.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: tuxemon.core.monster.Monster :returns: None """ message = T.format('combat_monster_choice', {"name": monster.name}) self.alert(message) x, y, w, h = self.client.screen.get_rect() rect = Rect(0, 0, w // 2.5, h // 4) rect.bottomright = w, h state = self.client.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: tuxemon.core.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.use_item: # "Monster used move!" context = { "user": getattr(user, "name", ''), "name": technique.name, "target": target.name } message = T.format(technique.use_item, context) else: message = '' try: audio.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) element_damage_key = MULT_MAP.get(result['element_multiplier']) if element_damage_key: m = T.translate(element_damage_key) message += "\n" + m for status in result.get("statuses", []): m = T.format( status.use_item, { "name": technique.name, "user": status.link.name if status.link else "", "target": status.carrier.name }) message += "\n" + m else: # assume this was an item used # handle the capture device if result["capture"]: message += "\n" + T.translate('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, T.translate('gotcha')), action_time) self._animation_in_progress = True return # generic handling of anything else else: msg_type = 'use_success' if result[ 'success'] else 'use_failure' template = getattr(technique, msg_type) if template: message += "\n" + T.translate(template) self.alert(message) self.suppress_phase_change(action_time) else: if result["success"]: self.suppress_phase_change() self.alert( T.format('combat_status_damage', { "name": target.name, "status": technique.name })) if result["success"] and target_sprite and 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: tuxemon.core.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( T.format('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 check_status( monster, "status_faint"): self.remove_monster_actions_from_queue(monster) self.faint_monster(monster) # If a monster fainted, exp was given, thus the exp bar should be updated # The exp bar must only be animated for the player's monsters # Enemies don't have a bar, doing it for them will cause a crash for monster in self.monsters_in_play[local_session.player]: self.animate_exp(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: tuxemon.core.technique.Technique :rtype: tuxemon.core.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: tuxemon.core.sprite.Sprite """ frame_time = .09 images = list() for fn in technique.images: image = graphics.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.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 for player in self.active_players: for mon in player.monsters: mon.end_combat() # clear action queue self._action_queue = list() # fade music out self.client.event_engine.execute_action("fadeout_music", [1000]) # remove any menus that may be on top of the combat state while self.client.current_state is not self: self.client.pop_state() self.client.push_state("FadeOutTransition", caller=self)
def load_graphics(self): """ Image become class attribute, so is shared. Eventually, implement some game-wide image caching """ image = graphics.load_and_scale(self.border_filename) ExpBar.border = GraphicBox(image)