diff --git a/assets/ui/cursor.png b/assets/ui/cursor.png new file mode 100644 index 0000000..d4666a4 Binary files /dev/null and b/assets/ui/cursor.png differ diff --git a/assets/ui/gradient_panel.png b/assets/ui/gradient_panel.png new file mode 100644 index 0000000..2b61a7c Binary files /dev/null and b/assets/ui/gradient_panel.png differ diff --git a/assets/ui/inventory_icons.png b/assets/ui/inventory_icons.png new file mode 100644 index 0000000..9135907 Binary files /dev/null and b/assets/ui/inventory_icons.png differ diff --git a/explore_state.py b/explore_state.py index a04727a..b49759f 100644 --- a/explore_state.py +++ b/explore_state.py @@ -14,6 +14,7 @@ from game import Game from map_definitions import MapDefinition from state_stack import StateStack +from world import World class ExploreState: @@ -25,6 +26,7 @@ def __init__(self, map_def: MapDefinition, start_tile_pos: Tuple[int, int], disp self.start_pos = start_tile_pos self.game = Game(display=display) self.game.setup(map_def, ACTIONS) + self.game.world = World(display) self.hero = Character(characters["hero"], self.game) self.hero.entity.set_tile_pos(*start_tile_pos, self.game) self.game.camera.set_follow(self.hero.entity) @@ -57,6 +59,6 @@ def process_event(self, event: pygame.event.Event): if isinstance(self.stack.top(), InGameMenuState): return self.stack.push( - InGameMenuState(self.display, self.manager, self.stack) + InGameMenuState(self.game.world, self.display, self.manager, self.stack) ) diff --git a/game.py b/game.py index fcd6255..124b346 100644 --- a/game.py +++ b/game.py @@ -6,9 +6,11 @@ from map_definitions import MapDefinition, Trigger, create_map_triggers from map_utils import Camera, CameraGroup, TmxMap from sprite_utils import Tile, Circle, Rectangle +from world import World class Game: + world: World tmx_map: TmxMap = None map_group: CameraGroup entity_group: CameraGroup @@ -88,6 +90,7 @@ def setup(self, map_def: MapDefinition, actions: Dict[str, Callable] = None): def update(self, dt: float = None): dt = dt if dt is not None else self.dt + self.world.update(dt) self.map_group.update() self.entity_group.update(game=self) for npc in self.npcs: diff --git a/ingame_menu_state.py b/ingame_menu_state.py index 707ec77..f6a4a99 100644 --- a/ingame_menu_state.py +++ b/ingame_menu_state.py @@ -4,29 +4,31 @@ from state_stack import StateStack from statemachine import StateMachine from ui_states import create_state +from world import World # stack state class InGameMenuState: should_exit = False - def __init__(self, display: pygame.Surface, manager: pygame_gui.UIManager, stack: StateStack = None): + def __init__(self, world: World, display: pygame.Surface, manager: pygame_gui.UIManager, stack: StateStack = None): self.stack = stack self.manager = manager self.display = display + self.world = world # create state machine for in game menu # controller = ("frontmenu", "items", "magic", "equip", "status") - self.menu_options = [ + self.menu_options = ( "Item", "Magic", "Equip", "Status", "Exit" - ] - controller = ("frontmenu",) + ) + self.class_names = ("front", "item",) state_classes = {} # This will store state classes, not instances. - for state_name in controller: + for state_name in self.class_names: state_class = create_state(state_name) assert state_class is not None, f"State {state_name} not found" assert state_name not in state_classes, f"State {state_name} already exists" @@ -37,7 +39,7 @@ def __init__(self, display: pygame.Surface, manager: pygame_gui.UIManager, stack self.state_machine.add(state_name, state_class(self, self.manager, self.display)) def enter(self) -> None: - self.state_machine.change("frontmenu") + self.state_machine.change(self.class_names[0]) def exit(self) -> None: pass diff --git a/sprite_utils/spritesheet.py b/sprite_utils/spritesheet.py index 9869ba8..747b351 100644 --- a/sprite_utils/spritesheet.py +++ b/sprite_utils/spritesheet.py @@ -3,7 +3,7 @@ def load_sprite_sheet(sprite_sheet_path: str, frame_width: int, frame_height: int, rows: int, columns: int): # Load the sprite sheet - sprite_sheet = pygame.image.load(sprite_sheet_path) + sprite_sheet = pygame.image.load(sprite_sheet_path).convert_alpha() # Create a list of frames frames = [] # Split the sprite sheet into frames diff --git a/state.py b/state.py deleted file mode 100644 index 5f9852b..0000000 --- a/state.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Protocol, runtime_checkable - - -@runtime_checkable -class State(Protocol): - def enter(self, **kwargs) -> None: - ... - - def exit(self) -> None: - ... - - def render(self, **kwargs) -> None: - ... - - def update(self, dt) -> None: - ... - - def process_event(self, event) -> None: - ... diff --git a/statemachine.py b/statemachine.py index da41963..00c4940 100644 --- a/statemachine.py +++ b/statemachine.py @@ -1,4 +1,30 @@ -from state import State +""" +A simple state machine implementation. +example: A state machine for a game menu, characters, etc. +A Main Menu can be in State Stack, while its submenus are in State Machine. +in State machine current state is always active, while in State Stack, only the top state is active. +in state machine, you can change state, while in state stack, you can push and pop state. +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class State(Protocol): + def enter(self, **kwargs) -> None: + ... + + def exit(self) -> None: + ... + + def render(self, **kwargs) -> None: + ... + + def update(self, dt) -> None: + ... + + def process_event(self, event) -> None: + ... class StateMachine: diff --git a/ui/__init__.py b/ui/__init__.py index c2361d0..809b5f1 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -1,3 +1,7 @@ +import os + +ASSETS_PATH = os.path.abspath('.') + '/assets/' + from .panel import * # noqa: F403 from .dialogue_panel import * # noqa: F403 from .selections import * # noqa: F403 @@ -5,3 +9,4 @@ from .progress_bar import * # noqa: F403 from .textbox import * # noqa: F403 from .text import * # noqa: F403 +from .icons import * # noqa: F403 diff --git a/ui/dialogue_panel.py b/ui/dialogue_panel.py index 251bd90..cf8aa03 100644 --- a/ui/dialogue_panel.py +++ b/ui/dialogue_panel.py @@ -3,7 +3,6 @@ import pygame import pygame_gui -from pygame_gui.core import IContainerLikeInterface from globals import ASSETS_PATH from .chunk_message import chunk_message @@ -25,7 +24,6 @@ def __init__(self, hero_image: str, hero_name: str, message: str, ) self.end_callback = end_callback self.should_exit = False - self.elements = [] self.message_chunks = [] self.current_chunk = 0 self.text_box = None @@ -69,13 +67,12 @@ def add_image(self, image_path: str, pos: Tuple[int, int] = (5, 5)): image_surface = pygame.transform.scale(image_surface, (new_width, new_height)) - avatar = pygame_gui.elements.UIImage( + _avatar = pygame_gui.elements.UIImage( pygame.Rect(pos, (new_width, new_height)), image_surface, manager=self.ui_manager, container=self ) - self.elements.append(avatar) def add_title_and_message(self, title: str, message: str): pos: Tuple[int, int] = (self.rect.width * AVATAR_WIDTH_RATIO + 10, 5) @@ -89,7 +86,6 @@ def add_title_and_message(self, title: str, message: str): container=self, object_id='@dialog_title' ) - self.elements.append(title) pos = (pos[0], pos[1] + line_height) size = (size[0], self.rect.height * 0.50) @@ -101,7 +97,6 @@ def add_title_and_message(self, title: str, message: str): object_id='@dialog_message', wrap_to_height=False, ) - self.elements.append(self.text_box) if len(self.message_chunks) > 1: # If there are multiple chunks, add a down arrow indicator @@ -113,7 +108,6 @@ def add_title_and_message(self, title: str, message: str): manager=self.ui_manager, container=self ) - self.elements.append(self.arrow_indicator) def update(self, dt: float): pass @@ -122,14 +116,10 @@ def render(self): pass def enter(self): - self.visible = 1 - for element in self.elements: - element.visible = 1 + self.show() def exit(self): - self.visible = 0 - for element in self.elements: - element.visible = 0 + self.hide() def process_event(self, event: pygame.event.Event): if event.type == pygame.KEYDOWN: diff --git a/ui/icons.py b/ui/icons.py new file mode 100644 index 0000000..604fc8e --- /dev/null +++ b/ui/icons.py @@ -0,0 +1,43 @@ +""" +The icon texture is 180x180 pixels and each icon is 18x18 pixels. Therefore, the texture +sheet can contain up to 100 icons. That’s more than enough for our needs! What we +want to do is take this texture, split it up into 18 x 18 pixels chunks, and create a sprite +for each icon we want to use. Each icon is given a simple id. + +Icon Category: + usable = 1, + accessory = 2, + weapon = 3, + armor = 4, + up_arrow = 5, + down_arrow = 6 +""" +from enum import Enum, auto +from typing import Dict + +import pygame + +from sprite_utils import load_sprite_sheet + + +class Icons: + def __init__(self, spritesheet_path: str): + self.spritesheet = load_sprite_sheet(spritesheet_path, 18, 18, 2, 10) + self.icon_category = { + "usable": 1, + "accessory": 2, + "weapon": 3, + "armor": 4, + "up_arrow": 5, + "down_arrow": 6 + } + self.icons: Dict[str, pygame.Surface] = {} + self.create_icons() + + def create_icons(self): + for category, idx in self.icon_category.items(): + self.icons[category] = self.spritesheet[idx] + + def get_icon(self, category: str) -> pygame.Surface: + assert category in self.icons, f"Icon id {category} not found" + return self.icons[category] diff --git a/ui/layout.py b/ui/layout.py index 344b431..dc5f864 100644 --- a/ui/layout.py +++ b/ui/layout.py @@ -62,6 +62,12 @@ def create_panel(self, panel_name: str) -> pygame_gui.elements.UIPanel: def kill_layout(self) -> None: self.kill() + def hide_layout(self) -> None: + self.hide() + + def show_layout(self) -> None: + self.show() + def render(self): for p in self.panels.keys(): panel = self.panels[p] diff --git a/ui/select_item.py b/ui/select_item.py index e1ed38d..5c8a9de 100644 --- a/ui/select_item.py +++ b/ui/select_item.py @@ -1,15 +1,21 @@ +from typing import Tuple + import pygame from pygame import Rect from pygame_gui import UIManager -from pygame_gui.elements import UILabel +from pygame_gui.elements import UILabel, UIImage +from pygame_gui.core import IContainerLikeInterface class SelectItem(UILabel): - def __init__(self, relative_rect: Rect, text: str, manager: UIManager, container=None, + def __init__(self, relative_rect: Rect, text: str, + manager: UIManager, container: IContainerLikeInterface = None, + icon_img: pygame.Surface = None, icon_size: Tuple[int, int] = (18, 18), base_color=(255, 255, 255), highlight_color=(204, 255, 0), active_color=(255, 0, 0)): # Initialize the UILabel parent class - super().__init__(relative_rect, text, manager, container) + self.parent = container + super().__init__(relative_rect, text, manager, self.parent) self.base_color = base_color self.highlight_color = highlight_color @@ -18,6 +24,17 @@ def __init__(self, relative_rect: Rect, text: str, manager: UIManager, container self.is_active = False self.update_colors() + if icon_img is not None: + self.icon_surf = icon_img.convert_alpha() + pos = (relative_rect.x-icon_size[0], relative_rect.y) + self.icon = UIImage( + relative_rect=Rect(pos, icon_size), + image_surface=self.icon_surf, + manager=manager, + container=self.parent, + parent_element=self.parent + ) + def update_colors(self): if self.is_active: self.text_colour = self.active_color @@ -56,6 +73,5 @@ def handle_event(self, event): if self.rect.collidepoint(event.pos): self.on_select() - def on_select(self): self.set_active() diff --git a/ui/selections.py b/ui/selections.py index 6655f90..15b0319 100644 --- a/ui/selections.py +++ b/ui/selections.py @@ -1,10 +1,9 @@ -from typing import List, Tuple, Callable +from typing import List, Tuple, Callable, Any, Dict import pygame import pygame_gui from pygame_gui import UIManager from pygame_gui.core import IContainerLikeInterface -from pygame_gui.elements import UITextBox from .select_item import SelectItem @@ -12,63 +11,73 @@ class Selections(pygame_gui.elements.UIPanel): - def __init__(self, title: str, options: List[str], columns: int, position: Tuple[float, float], width: float, - manager: UIManager, - container: IContainerLikeInterface = None, show_info_popup: bool = False, end_callback: Callable = None): - # Call the parent class' init method + def __init__(self, columns: int, position: Tuple[float, float], width: float, + manager: UIManager, container: IContainerLikeInterface = None, + title: str = None, + options: List[str] = None, data: List[Dict[str, Any]] = None, render_data_item: Callable = None, + end_callback: Callable = None): + + assert options or data, "Either options or data must be provided" super().__init__( - relative_rect=pygame.Rect(position, (width, len(options) * LINE_HEIGHT + 50)), - starting_height=10, + relative_rect=pygame.Rect(position, (width, len(options or data) * LINE_HEIGHT + 50)), + starting_height=11, manager=manager, container=container, object_id='@text_panel_bg' ) + self.manager = manager + self.container = container self.should_exit = False + self.width = width + self.columns = columns self._on_selection = end_callback - self.elements = [] self.options = options + self.data = data + self.render_data_item = render_data_item self.selection = None self.current_selection_idx = 0 - # Create a title label - title_rect = pygame.Rect((0, 0), (width, 50)) - self.title = pygame_gui.elements.UILabel( - relative_rect=title_rect, - text=title, - manager=manager, - container=self, - object_id='@text_title' - ) - self.elements.append(self.title) + self.title = title + self.select_items: List[SelectItem] = [] + self._create_ui() + + def _create_ui(self): + if self.title is not None: + title_rect = pygame.Rect((0, 0), (self.width, 50)) + self.title = pygame_gui.elements.UILabel( + relative_rect=title_rect, + text=self.title, + manager=self.manager, + container=self, + object_id='@text_title' + ) # select items columns - self.select_items: List[SelectItem] = [] start_y = self.title.relative_rect.bottom - rows_per_column = len(options) // columns + (len(options) % columns > 0) - for idx, option in enumerate(options): + + select_options = self.options or self.data + rows_per_column = len(select_options) // self.columns + (len(select_options) % self.columns > 0) + for idx, option in enumerate(select_options): column = idx // rows_per_column row = idx % rows_per_column - item_x = column * (width / columns) + item_x = column * (self.width / self.columns) item_y = start_y + (row * LINE_HEIGHT) - item = SelectItem( - relative_rect=pygame.Rect((item_x, item_y), ((width - 20) / columns, LINE_HEIGHT)), - text=option, - manager=manager, - container=self + item: SelectItem = ( + self.render_data_item(option, item_x, item_y, self.manager, self) if self.render_data_item + else SelectItem( + relative_rect=pygame.Rect((item_x, item_y), ((self.width - 20) / self.columns, LINE_HEIGHT)), + text=option, + manager=self.manager, + container=self + ) ) item.user_data = option self.select_items.append(item) - self.elements.append(item) if self.select_items: self.select_items[self.current_selection_idx].highlight() - self.manager = manager - - self.info_popup = None - if show_info_popup: - self.show_info_popup() def change_selection(self, change): if self.selection is None: @@ -90,26 +99,12 @@ def handle_on_selection(self, user_data): self.selection = user_data if self._on_selection: self._on_selection(user_data) - print(f"Selected: {self.selection}") + print(f"selections.py sel: {self.selection}") self.should_exit = True def get_selection(self): return self.selection - def show_info_popup(self): - text = "Use UP DOWN arrow keys to navigate. or mouse click on the item." - print(text) - pos = (self.rect.x + self.rect.width, self.rect.y) - size = (220, 200) - self.info_popup = UITextBox( - html_text=text, - relative_rect=pygame.Rect(pos, size), - manager=self.manager, - container=self, - object_id='@text_message', - ) - self.elements.append(self.info_popup) - def update(self, dt: float): pass @@ -117,14 +112,10 @@ def render(self): pass def enter(self): - self.visible = 1 - for child in self.elements: - child.visible = 1 + self.show() def exit(self): - self.visible = 0 - for child in self.elements: - child.visible = 0 + self.hide() def process_event(self, event: pygame.event.Event): for item in self.select_items: diff --git a/ui_states/__init__.py b/ui_states/__init__.py index 75b258b..2b3daf5 100644 --- a/ui_states/__init__.py +++ b/ui_states/__init__.py @@ -1,2 +1,2 @@ -from .frontmenu_state import * # noqa: F403 +from .front_menu_state import * # noqa: F403 from .state_factory import * # noqa: F403 diff --git a/ui_states/frontmenu_state.py b/ui_states/front_menu_state.py similarity index 89% rename from ui_states/frontmenu_state.py rename to ui_states/front_menu_state.py index 9080f95..a25aeb7 100644 --- a/ui_states/frontmenu_state.py +++ b/ui_states/front_menu_state.py @@ -50,6 +50,8 @@ def __init__(self, parent: StackInterface, manager: pygame_gui.UIManager, displa def _on_selection(self, selection: str) -> None: assert selection in self.parent.menu_options, f"Invalid selection {selection}" + assert selection.lower() in self.parent.class_names, \ + f"Make sure -{selection.lower()}- exists in ingame_menu_state.class_names" if selection == "Exit": self.close_menu() else: @@ -60,10 +62,10 @@ def close_menu(self) -> None: self.parent.should_exit = True def enter(self) -> None: - pass + self.layout.show_layout() def exit(self) -> None: - pass + self.layout.hide_layout() def render(self) -> None: self.layout.render() diff --git a/ui_states/item_menu_state.py b/ui_states/item_menu_state.py new file mode 100644 index 0000000..2663911 --- /dev/null +++ b/ui_states/item_menu_state.py @@ -0,0 +1,103 @@ +from typing import Any, Dict + +import pygame +import pygame_gui +from pygame_gui import UIManager +from pygame_gui.core import IContainerLikeInterface + +from state_stack import StackInterface +from ui import Selections, create_title, SelectItem +from ui.layout import Layout + + +class ItemMenuState: + def __init__(self, parent: StackInterface, manager: pygame_gui.UIManager, display: pygame.Surface): + from ingame_menu_state import InGameMenuState + assert isinstance(parent, InGameMenuState) + self.parent = parent + self.manager = manager + self.display = display + self.layout = None + self.category_menu = None + self.category_menu_options = ["Use", "Key Items", "Exit"] + self.title = None + self.item_menus = None + + def _create_layout(self): + self.layout = Layout(self.manager) + self.layout.contract("screen", 120, 60) + self.layout.split_horz("screen", "top", "bottom", 0.2) + self.layout.split_vert("top", "title", "category", 0.6) + self.layout.split_horz("bottom", "mid", "inv", 0.2) + self._create_category_menu() + + def _create_category_menu(self): + category_pos = (self.layout.left("category"), self.layout.top("category")) + self.category_menu = Selections( + container=self.layout, + title="Select an option", + options=self.category_menu_options, + position=category_pos, + width=self.layout.panels["category"].width, + columns=1, + manager=self.manager, + end_callback=self._on_category_selection + ) + + top_size = (self.layout.panels["title"].width, self.layout.panels["title"].height) + self.title = create_title( + html_text="Items", + pos=(self.layout.left("title") + top_size[0] * 0.02, self.layout.mid_y("title") - 20), + size=top_size, + manager=self.manager, + container=self.layout, + ) + + def _create_use_item_menu(self): + def _render_data_item( + item: Dict[str, Any], x: float, y: float, manager: UIManager, container: IContainerLikeInterface + ) -> SelectItem: + return self.parent.world.draw_item(item, x, y, manager, container) + + inv_pos = (self.layout.left("inv"), self.layout.top("inv")) + self.item_menus = Selections( + container=self.layout, + title="Select an use item", + data=self.parent.world.items, + render_data_item=_render_data_item, + position=inv_pos, + width=self.layout.panels["inv"].width, + columns=1, + manager=self.manager, + end_callback=self._on_category_selection + ) + + def _on_category_selection(self, selection: str) -> None: + assert selection in self.category_menu_options, f"Invalid selection {selection}" + if selection == "Exit": + self.close_menu() + else: + self.category_menu.hide() + self._create_use_item_menu() + + def close_menu(self) -> None: + self.layout.kill_layout() + self.parent.state_machine.change("front") + + def enter(self) -> None: + self._create_layout() + + def exit(self) -> None: + self.layout.kill_layout() + + def render(self) -> None: + self.layout.render() + + def update(self, dt) -> None: + pass + + def process_event(self, event: pygame.event.Event): + self.category_menu.process_event(event) + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self.close_menu() diff --git a/ui_states/state_factory.py b/ui_states/state_factory.py index bda8354..266b285 100644 --- a/ui_states/state_factory.py +++ b/ui_states/state_factory.py @@ -3,8 +3,11 @@ def create_state(state_name) -> Callable: - if state_name == "frontmenu": - from .frontmenu_state import FrontMenuState + if state_name == "front": + from .front_menu_state import FrontMenuState return FrontMenuState + if state_name == "item": + from .item_menu_state import ItemMenuState + return ItemMenuState else: raise ValueError(f"Unknown state {state_name}") \ No newline at end of file diff --git a/world.py b/world.py index 9d215c2..5508e6f 100644 --- a/world.py +++ b/world.py @@ -3,9 +3,16 @@ and the amount of time that has passed. Later on it will also track quests and current party members, and be involved with loading and saving. """ -from typing import TypedDict, List +from typing import TypedDict, List, Any, Dict +import pygame +import pygame_gui +from pygame_gui import UIManager +from pygame_gui.core import IContainerLikeInterface + +from globals import ASSETS_PATH from items import item_db +from ui import Icons, SelectItem class InventoryItem(TypedDict): @@ -18,13 +25,22 @@ class World: items: List[InventoryItem] key_items: List[InventoryItem] - def __init__(self): + def __init__(self, renderer: pygame.Surface): + self.renderer = renderer self.time = 0 self.gold = 0 self.items = [ - {"id": 1, "count": 2} + {"id": 1, "count": 2}, + {"id": 0, "count": 1}, + {"id": 2, "count": 1}, + ] + self.key_items = [ + {"id": 3, "count": 1}, ] - self.key_items = [] + self.icons = Icons(ASSETS_PATH + "ui/inventory_icons.png") + + def update(self, dt: float) -> None: + self.time += dt def add_item(self, item_id: int, count: int = 1) -> None: """ @@ -87,17 +103,22 @@ def time_as_string(self) -> str: seconds = int(self.time % 60) return f"{hours}::{minutes}:{seconds:02}" - def enter(self) -> None: - pass + def draw_item(self, item: Dict[str, Any], x: float, y: float, manager: UIManager, container: IContainerLikeInterface) -> SelectItem: + assert "id" in item, f"Item {item} does not have an id" + assert "count" in item, f"Item {item} does not have a count" + item_id = item["id"] + count = item["count"] + item_def = item_db[item_id] + icon = self.icons.get_icon(item_def.category.name.lower()) + icon_w = 18 + text_w = len(item_def.name) * 10 + icon_w + return SelectItem( + relative_rect=pygame.Rect((x+icon_w+5, y), (text_w, 25)), + text=f"{item_def.name} x{count}", + manager=manager, + container=container, + icon_img=icon, + icon_size=(icon_w, icon_w) + ) - def exit(self) -> None: - pass - - def render(self) -> None: - pass - - def update(self, dt: float) -> None: - self.time += dt - def process_event(self, event): - pass