diff --git a/source/actions.py b/source/actions.py index de66784..535d410 100644 --- a/source/actions.py +++ b/source/actions.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -import colors +from typing import List, TYPE_CHECKING from floor_map import FloorMap @@ -15,7 +13,7 @@ class BaseAction: def __init__(self, entity): self.entity = entity - def apply(self) -> bool: + def perform(self) -> bool: """return True if the game state should be progressed""" raise NotImplementedError() @@ -25,12 +23,12 @@ class QuitAction(BaseAction): """override the base __init__, as no entity is needed""" pass - def apply(self) -> bool: + def perform(self) -> bool: raise SystemExit() class WaitAction(BaseAction): - def apply(self) -> bool: + def perform(self) -> bool: return True @@ -41,7 +39,7 @@ class MovementAction(BaseAction): self.xdir = xdir self.ydir = ydir - def apply(self) -> bool: + def perform(self) -> bool: dest_x = self.entity.x + self.xdir dest_y = self.entity.y + self.ydir @@ -52,7 +50,7 @@ class MovementAction(BaseAction): return False if not floor_map.tiles["walkable"][dest_x, dest_y]: return False - if floor_map.get_entity_at(dest_x, dest_y, unwalkable_only=True) is not None: + if floor_map.get_all_entities_at(dest_x, dest_y, unwalkable_only=True): return False self.entity.set_pos(dest_x, dest_y) @@ -65,11 +63,16 @@ class MeleeAction(BaseAction): self.xdir = xdir self.ydir = ydir - def apply(self) -> bool: + def perform(self) -> bool: dest_x = self.entity.x + self.xdir dest_y = self.entity.y + self.ydir - target = self.entity.floor_map.get_entity_at(dest_x, dest_y, unwalkable_only=True) + targets = self.entity.floor_map.get_all_entities_at(dest_x, dest_y, unwalkable_only=True) + + if not targets: + return False + + target = targets.pop() if not target or not target.stats: return False @@ -102,11 +105,42 @@ class BumpAction(BaseAction): self.xdir = xdir self.ydir = ydir - def apply(self) -> bool: + def perform(self) -> bool: dest_x = self.entity.x + self.xdir dest_y = self.entity.y + self.ydir - if self.entity.floor_map.get_entity_at(dest_x, dest_y, unwalkable_only=True): - return MeleeAction(self.entity, self.xdir, self.ydir).apply() + if self.entity.floor_map.get_all_entities_at(dest_x, dest_y, unwalkable_only=True): + return MeleeAction(self.entity, self.xdir, self.ydir).perform() else: - return MovementAction(self.entity, self.xdir, self.ydir).apply() + return MovementAction(self.entity, self.xdir, self.ydir).perform() + + +class PickupAction(BaseAction): + """Pickup an item at the entity's location""" + def perform(self) -> bool: + x = self.entity.x + y = self.entity.y + + floor_map: FloorMap = self.entity.floor_map + + item_stack: List[Entity] = floor_map.get_all_entities_at(x, y, items_only=True) + + if len(item_stack) == 0: + return False + elif len(item_stack) == 1: + floor_map.entities.remove(item_stack[0]) + self.entity.inventory.insert(item_stack[0]) + else: + options: List[str] = [] + for item in item_stack:#not pythonic, IDC + options.append(item.name) + + from event_handlers import OptionSelector #circular imports are a pain + floor_map.engine.event_handler = OptionSelector( + floor_map.engine, + floor_map.engine.event_handler, + options, + lambda x: (floor_map.entities.remove(item_stack[x]), self.entity.inventory.insert(item_stack[x])), + ) + + return True diff --git a/source/engine.py b/source/engine.py index 55b2ee6..3441bf5 100644 --- a/source/engine.py +++ b/source/engine.py @@ -8,6 +8,7 @@ from tcod.map import compute_fov from entity import Entity from message_log import Message, MessageLog from floor_map import FloorMap #TODO: replace with "DungeonMap" or similar +from actions import BaseAction from render_functions import render_hp_bar, render_names_at @@ -55,13 +56,17 @@ class Engine: Processes monster AI and other things. Returns `True` if the game state should be progressed. """ - result = False + actions: List[BaseAction] = [] #make the entities think and act for entity in set(self.floor_map.entities) - {self.player}: if entity.ai: - action = entity.ai.process() - result |= action.apply() + actions.append(entity.ai.process()) + + result = False + + for action in actions: + result |= action.perform() return result diff --git a/source/entity.py b/source/entity.py index adbbed9..ed1ba0d 100644 --- a/source/entity.py +++ b/source/entity.py @@ -3,10 +3,16 @@ from __future__ import annotations import copy from typing import Optional, Tuple, Type -from ai import BaseAI from stats import Stats +from ai import BaseAI +from useable import BaseUseable +from inventory import Inventory class Entity: + stats: Stats + ai: BaseAI + useable: BaseUseable + def __init__( self, x: int = 0, @@ -20,6 +26,10 @@ class Entity: #monster-specific stuff ai_class: Type[BaseAI] = None, stats: Stats = None, + + #item-specific stuff + useable: BaseUseable = None, + inventory: Inventory = None, ): self.x = x self.y = y @@ -33,9 +43,12 @@ class Entity: if ai_class: self.ai: Optional[BaseAI] = ai_class(self) - if stats: - self.stats = stats - self.stats.entity = self + self.stats = stats + self.stats.entity = self + + #item-specific stuff + self.useable = useable + self.inventory = inventory #generic entity stuff def spawn(self, x: int, y: int, floor_map): @@ -53,3 +66,7 @@ class Entity: #monster-specific stuff def is_alive(self) -> bool: return bool(self.ai) + + #item-specific stuff + def is_item(self) -> bool: + return bool(self.useable) \ No newline at end of file diff --git a/source/entity_types.py b/source/entity_types.py index 98672c3..2005aec 100644 --- a/source/entity_types.py +++ b/source/entity_types.py @@ -1,6 +1,7 @@ from entity import Entity from ai import BaseAI, AggressiveWhenSeen from stats import Stats +from inventory import Inventory player = Entity( char = "@", @@ -9,6 +10,7 @@ player = Entity( walkable = False, ai_class = BaseAI, #TODO: remove this or dummy it out stats = Stats(hp = 10, attack = 2, defense = 1), + inventory=Inventory(), ) #gobbos diff --git a/source/event_handlers.py b/source/event_handlers.py index c3ce119..ca77820 100644 --- a/source/event_handlers.py +++ b/source/event_handlers.py @@ -1,10 +1,16 @@ from __future__ import annotations -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING import tcod import colors -from actions import BaseAction, QuitAction, BumpAction, WaitAction +from actions import ( + BaseAction, + QuitAction, + BumpAction, + WaitAction, + PickupAction, +) if TYPE_CHECKING: from engine import Engine @@ -51,6 +57,10 @@ WAIT_KEYS = { tcod.event.KeySym.CLEAR, } +PICKUP_KEYS = { + tcod.event.KeySym.COMMA, +} + CURSOR_SCROLL_KEYS = { tcod.event.KeySym.UP: -1, tcod.event.KeySym.DOWN: 1, @@ -61,6 +71,11 @@ CURSOR_SCROLL_KEYS = { tcod.event.KeySym.KP_8: -1, } +CURSOR_CONFIRM_KEYS = { + tcod.event.KeySym.RETURN, + tcod.event.KeySym.SPACE, +} + #the event handlers are one part of the engine class EventHandler(tcod.event.EventDispatch[BaseAction]): engine: Engine @@ -87,7 +102,7 @@ class EventHandler(tcod.event.EventDispatch[BaseAction]): if action is None: continue - result |= action.apply() + result |= action.perform() return result @@ -109,9 +124,18 @@ class GameplayHandler(EventHandler): if key in WAIT_KEYS: return WaitAction(player) + if key in PICKUP_KEYS: + return PickupAction(player) + + if key == tcod.event.KeySym.o: #TODO: temove this + self.engine.event_handler = OptionSelector(self.engine, self, ["zero", "one", "two", "three"], lambda x: print("You chose", x)) #TODO: remove this + if key == tcod.event.KeySym.BACKQUOTE: #lowercase tilde self.engine.event_handler = LogHistoryViewer(self.engine, self) + if key == tcod.event.KeySym.TAB: + # self.engine.event_handler = InventoryViewer(self.engine, self) #TODO: deal with this + def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: if self.engine.floor_map.in_bounds(event.tile.x, event.tile.y): self.engine.mouse_location = event.tile.x, event.tile.y @@ -175,12 +199,80 @@ class LogHistoryViewer(EventHandler): elif adjust > 0 and self.cursor == self.log_length - 1: pass #do nothing else: - self.cursor = max(0, min(self.log_length - 1, self.cursor + adjust)) #TODO: nicer scroll down + self.cursor = max(0, min(self.log_length - 1, self.cursor + adjust)) elif event.sym == tcod.event.KeySym.HOME: self.cursor = 0 elif event.sym == tcod.event.KeySym.END: self.cursor = self.log_length - 1 + else: + #return to the game + self.engine.event_handler = self.baseEventHandler + +#generic tool +class OptionSelector(EventHandler): + baseEventHandler: EventHandler + + def __init__(self, engine: Engine, baseEventHandler: EventHandler, options: List[str], callback: function): + super().__init__(engine) + self.baseEventHandler = baseEventHandler + self.options = options + self.callback = callback + self.length = len(options) + self.cursor = 0 + + def render(self, console: tcod.console.Console) -> None: + super().render(console) + + select_console = tcod.console.Console(console.width - 20, console.height - 16) + + #rendering a nice list window + select_console.draw_frame( + 0,0, select_console.width, select_console.height, + # "╔═╗║ ║╚═╝" + decoration="\\x/x x/x\\", + fg=colors.terminal_dark, bg=colors.black + ) + select_console.print_box( + 0, 0, select_console.width, select_console.height, + string = "Select One", + alignment=tcod.constants.CENTER, + fg=colors.terminal_light, bg=colors.black + ) + + #render the cursor & options + offset = 0 + for option in self.options: + select_console.print( + 4, 2 + offset, + string = option, + fg=colors.terminal_light, bg=colors.black, + ) + offset += 1 + + select_console.print(2, 2 + self.cursor, string = ">", fg=colors.terminal_light, bg=colors.black) + + select_console.blit(console, 10, 8) + + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: + if event.sym in CURSOR_SCROLL_KEYS: + adjust = CURSOR_SCROLL_KEYS[event.sym] + if adjust < 0 and self.cursor == 0: + pass #do nothing + elif adjust > 0 and self.cursor == self.length - 1: + pass #do nothing + else: + self.cursor = max(0, min(self.length - 1, self.cursor + adjust)) + + elif event.sym in CURSOR_CONFIRM_KEYS: + #got the answer + self.callback(self.cursor) + self.engine.event_handler = self.baseEventHandler + + elif event.sym == tcod.event.KeySym.HOME: + self.cursor = 0 + elif event.sym == tcod.event.KeySym.END: + self.cursor = self.length - 1 else: #return to the game self.engine.event_handler = self.baseEventHandler \ No newline at end of file diff --git a/source/floor_map.py b/source/floor_map.py index 0b14022..8fbc4fd 100644 --- a/source/floor_map.py +++ b/source/floor_map.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Iterable, Optional, Set, TYPE_CHECKING +from typing import Iterable, List, Optional, Set, TYPE_CHECKING import numpy as np from tcod.console import Console @@ -33,16 +33,18 @@ class FloorMap: def in_bounds(self, x: int, y: int) -> bool: return 0 <= x < self.width and 0 <= y < self.height - def get_entity_at(self, x: int, y: int, *, unwalkable_only: bool = False) -> Optional[Entity]: + def get_all_entities_at(self, x: int, y: int, *, unwalkable_only: bool = False, items_only: bool = False) -> List[Entity]: + result: List[Entity] = [] + for entity in self.entities: if entity.x == x and entity.y == y: - if unwalkable_only: - if not entity.walkable: - return entity - else: - return entity + if unwalkable_only and entity.walkable: + continue + if items_only and not entity.is_item(): + continue + result.append(entity) - return None + return result def render(self, console: Console) -> None: console.rgb[0:self.width, 0:self.height] = np.select( diff --git a/source/inventory.py b/source/inventory.py new file mode 100644 index 0000000..25e6676 --- /dev/null +++ b/source/inventory.py @@ -0,0 +1,31 @@ +from __future__ import annotations +from typing import Optional, Set, TYPE_CHECKING + +if TYPE_CHECKING: + from entity import Entity + +class Inventory: + """Handles inventory for an Entity""" + _contents: Set[Entity] + + def __init__(self, contents: Set[Entity] = set()): + self._contents = contents + + def insert(self, entity: Entity) -> bool: + if entity in self._contents: + return False + + self._contents.add(entity) + return True + + def access(self, key: str) -> Optional[Entity]: + return self._contents[key] + + def remove(self, key: str) -> Optional[Entity]: + item = self._contents[key] + self._contents.remove(key) + return item + + @property + def contents(self) -> Set[Entity]: + return self._contents diff --git a/source/stats.py b/source/stats.py index c2ebaf6..ddbe3f5 100644 --- a/source/stats.py +++ b/source/stats.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import colors from event_handlers import GameOverHandler +from useable import BaseUseable if TYPE_CHECKING: from engine import Engine @@ -52,4 +53,5 @@ class Stats: self.entity.color = (191, 0, 0) self.entity.walkable = True self.entity.ai = None #TODO: Could decay over time + self.entity.useable = BaseUseable(self.entity) #TMP self.entity.name = f"Dead {self.entity.name}" diff --git a/source/useable.py b/source/useable.py new file mode 100644 index 0000000..a2e16bf --- /dev/null +++ b/source/useable.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from typing import List, Tuple, TYPE_CHECKING + +from actions import BaseAction, MeleeAction, MovementAction, WaitAction + +if TYPE_CHECKING: + from entity import Entity + +class BaseUseable: + """Base type for useable items, with various utilities""" + entity: Entity + + def __init__(self, entity): + self.entity = entity + + def activate(self) -> BaseAction: + """Activate this item's effect""" + raise NotImplementedError() + + +class Consumable(BaseUseable): + """This disappears after use""" + def activate(self) -> BaseAction: + pass + +#TODO: finish useable items, with distinct effects \ No newline at end of file