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