diff --git a/source/actions.py b/source/actions.py index febb8c3..d83dc30 100644 --- a/source/actions.py +++ b/source/actions.py @@ -4,6 +4,7 @@ from typing import List, Optional, TYPE_CHECKING import colors from floor_map import FloorMap from inventory import Inventory +from useable import BaseUseable if TYPE_CHECKING: from engine import Engine @@ -132,9 +133,13 @@ class PickupAction(BaseAction): if len(item_stack) == 0: return False elif len(item_stack) == 1: + msg = "you picked up a(n) {item_stack[0].name}" + if item_stack[0].useable.current_stack > 1: + msg = msg = f"you picked up a stack of {item_stack[0].useable.current_stack} {item_stack[0].name}" + floor_map.entities.remove(item_stack[0]) self.entity.inventory.insert(item_stack[0]) - engine.message_log.add_message(f"you picked up a(n) {item_stack[0].name}", color=colors.terminal_light) + engine.message_log.add_message(msg, color=colors.terminal_light) else: from event_handlers import OptionSelector #circular imports are a pain @@ -155,18 +160,24 @@ class PickupAction(BaseAction): #utils def pickup_callback(self, engine: Engine, floor_map: FloorMap, entity: Entity, item: Entity) -> None: + msg = "you picked up a(n) {item.name}" + if item.useable.current_stack > 1: + msg = msg = f"you picked up a stack of {item.useable.current_stack} {item.name}" + floor_map.entities.remove(item) entity.inventory.insert(item) - engine.message_log.add_message(f"you picked up a(n) {item.name}", color=colors.terminal_light) + engine.message_log.add_message(msg, color=colors.terminal_light) class DropAction(BaseAction): """Drop an item from an entity's inventory at the entity's location""" index: int + display_callback: function - def __init__(self, entity: Entity, index: int): + def __init__(self, entity: Entity, index: int, display_callback: function): """override the base __init__""" super().__init__(entity) self.index = index + self.display_callback = display_callback def perform(self) -> bool: x = self.entity.x @@ -183,6 +194,81 @@ class DropAction(BaseAction): floor_map.entities.add(item) + if self.display_callback: #adjust the cursor + self.display_callback(-1) + engine.message_log.add_message(f"you dropped a(n) {item.name}", color=colors.terminal_light) return True + + +class DropPartialStackAction(BaseAction): + """Drop part of a stack from an entity's inventory at the entity's location""" + index: int + amount: int + display_callback: function + + def __init__(self, entity: Entity, index: int, amount: int, display_callback: function): + """override the base __init__""" + super().__init__(entity) + self.index = index + self.amount = amount + self.display_callback = display_callback + + def perform(self) -> bool: + x = self.entity.x + y = self.entity.y + + inventory: Inventory = self.entity.inventory + floor_map: FloorMap = self.entity.floor_map + engine: Engine = floor_map.engine + + item: Entity = inventory.access(self.index) + + new_item: Entity = item.spawn(x, y, floor_map) + + item.useable.current_stack -= self.amount + new_item.useable.current_stack = self.amount + + if self.display_callback: #adjust the cursor + self.display_callback(-1) + + engine.message_log.add_message(f"you dropped a stack of {new_item.useable.current_stack} {new_item.name}", color=colors.terminal_light) + + return True + + +class UsageAction(BaseAction): + """Use an item from an entity's inventory, removing it if needed""" + index: int + target: Entity + display_callback: function + + def __init__(self, entity: Entity, index: int, target: Entity, display_callback: function): + """override the base __init__""" + super().__init__(entity) + self.index = index + self.target = target + self.display_callback = display_callback + + def perform(self) -> bool: + inventory: Inventory = self.entity.inventory + engine: Engine = self.entity.floor_map.engine + + item: Entity = inventory.access(self.index) + usable: BaseUseable = item.useable + + if usable.apply(self.target.stats) and usable.is_stack_empty(): + #remove the item from the inventory + inventory.discard(self.index) + + if self.display_callback: + self.display_callback(-1) + + msg: str = usable.get_used_msg(item.name) + if not msg: + msg = f"you used a(n) {item.name}" + + engine.message_log.add_message(msg, color=colors.terminal_light) + + return True \ No newline at end of file diff --git a/source/entity_types.py b/source/entity_types.py index 091d907..a218699 100644 --- a/source/entity_types.py +++ b/source/entity_types.py @@ -2,6 +2,7 @@ from entity import Entity from ai import BaseAI, AggressiveWhenSeen from stats import Stats from inventory import Inventory +from useable import PotionOfHealing #player and utils player = Entity( @@ -30,8 +31,15 @@ gobbo_red = Entity( name = "Red Gobbo", walkable = False, ai_class = AggressiveWhenSeen, - stats = Stats(hp = 5, attack = 1, defense = 0), #this guy can't catch a break + stats = Stats(hp = 1, attack = 2, defense = 0), #this guy can't catch a break ) #items - conumables -#TODO: potion of healing entity \ No newline at end of file +potion_of_healing = Entity( + char = "!", + color = (0, 0, 255), + name = "Potion of Healing", + walkable = True, + useable=PotionOfHealing(current_stack=1, maximum_stack=255, consumable=True), +) + diff --git a/source/event_handlers.py b/source/event_handlers.py index eab556a..ab1effe 100644 --- a/source/event_handlers.py +++ b/source/event_handlers.py @@ -11,6 +11,8 @@ from actions import ( WaitAction, PickupAction, DropAction, + DropPartialStackAction, + UsageAction, ) if TYPE_CHECKING: @@ -245,9 +247,12 @@ class InventoryViewer(EventHandler): #render the cursor & inventory contents offset: int = 0 for item in self.entity.inventory.contents: + msg = item.name + if item.useable.current_stack > 1: + msg = f"{msg} x{item.useable.current_stack}" inner_console.print( 4, 2 + offset, - string = item.name, + string = msg, fg=colors.terminal_light, bg=colors.black, ) offset += 1 @@ -280,10 +285,20 @@ class InventoryViewer(EventHandler): #drop an item form this entity's inventory item: Entity = self.entity.inventory.access(self.cursor) + #defaults + options = ["Use", "Drop", "Back"] + callback = lambda x: self.default_selector_callback(x) + + #different options for different situations + if item.useable.current_stack > 1: + options = ["Use", "Drop 1", "Drop All", "Back"] + callback = lambda x: self.stack_selector_callback(x) + + #TODO: drop 1, drop all for stacks self.engine.event_handler = OptionSelector(self.engine, self, - title=f"Drop The {item.name}?", - options=["Yes", "No"], - callback=lambda x: self.selector_callback(x) + title=item.name, + options=options, + callback=callback ) #TODO: hotkeys via a config @@ -297,22 +312,49 @@ class InventoryViewer(EventHandler): self.engine.event_handler = self.parent_handler #utils - def selector_callback(self, answer: int) -> Optional[BaseAction]: - #TODO: insert a sub-selection box to choose what to do with this item - if answer == 0: #TODO: Use, Drop, Back + def default_selector_callback(self, selected: int) -> Optional[BaseAction]: + if selected == 0: #Use + return self.use() + elif selected == 1: #Drop return self.drop() + elif selected == 2: #Back + return None #the selector does the change + + def stack_selector_callback(self, selected: int) -> Optional[BaseAction]: + if selected == 0: #Use + return self.use() + elif selected == 1: #Drop 1 + return self.drop_partial_stack(1) + elif selected == 2: #Drop all + return self.drop() + elif selected == 3: #Back + return None #the selector does the change + + def use(self) -> Optional[BaseAction]: + """Drop the item at the cursor's position, and adjust the cursor if needed.""" + if self.length > 0: + index = self.cursor + + return UsageAction(self.entity, index, self.entity, lambda x: self.adjust_length(x)) + + def drop_partial_stack(self, amount: int) -> Optional[BaseAction]: + """Drop part of an item stack at the cursor's position, and adjust the cursor if needed.""" + if self.length > 0: + index = self.cursor + + return DropPartialStackAction(self.entity, index, amount, lambda x: self.adjust_length(x)) def drop(self) -> Optional[BaseAction]: """Drop the item at the cursor's position, and adjust the cursor if needed.""" if self.length > 0: index = self.cursor - #bounds - self.length -= 1 - if self.cursor >= self.length: - self.cursor = self.length - 1 - - return DropAction(self.entity, index) + return DropAction(self.entity, index, lambda x: self.adjust_length(x)) + + def adjust_length(self, amount: int): + self.length += amount + if self.cursor >= self.length: + self.cursor = self.length - 1 #generic tools diff --git a/source/inventory.py b/source/inventory.py index 55f9f29..9857477 100644 --- a/source/inventory.py +++ b/source/inventory.py @@ -11,11 +11,16 @@ class Inventory: def __init__(self, contents: List[Entity] = []): self._contents = contents - def insert(self, entity: Entity) -> bool: - if entity in self._contents: + def insert(self, item: Entity) -> bool: + if item in self._contents: return False - self._contents.append(entity) + #check for stacking + if item.useable.maximum_stack > 0: + if self.try_stack_merge(item): + return True + + self._contents.append(item) return True def access(self, index: int) -> Optional[Entity]: @@ -29,9 +34,25 @@ class Inventory: return None else: return self._contents.pop(index) + + def discard(self, index: int) -> None: + if index < 0 or index >= len(self._contents): + pass + else: + self._contents.pop(index) @property def contents(self) -> List[Entity]: return self._contents + + #utils + def try_stack_merge(self, new_item: Entity): + for item in self._contents: + if item.useable.is_stack_mergable(new_item.useable): + #TODO: add a callback in the entity if other components need to be tweaked down the road + item.useable.current_stack += new_item.useable.current_stack + new_item.useable.current_stack = 0 #just in case + return True + return False #TODO: items need a weight, inventory needs a max capacity \ No newline at end of file diff --git a/source/main.py b/source/main.py index adf6ba5..afa2aa3 100755 --- a/source/main.py +++ b/source/main.py @@ -30,7 +30,7 @@ def main() -> None: engine = Engine( #is created externally, because - floor_map = generate_floor_map(map_width, map_height, room_width_max=12, room_height_max=12), + floor_map = generate_floor_map(map_width, map_height, room_width_max=12, room_height_max=12, room_items_max=4), ui_height = ui_height, initial_log= [ diff --git a/source/procgen.py b/source/procgen.py index 4060293..4ce7052 100644 --- a/source/procgen.py +++ b/source/procgen.py @@ -50,13 +50,19 @@ class RectangularRoom: self.y2 >= other.y1 ) -def spawn_monsters(floor_map: FloorMap, room: RectangularRoom, room_monsters_max: int) -> None: - monster_count = random.randint(0, room_monsters_max) +def spawn_monsters(floor_map: FloorMap, room: RectangularRoom, room_monsters_max: int, floor_monsters_max: int) -> None: + if "monster_count" not in floor_map.procgen_cache: + floor_map.procgen_cache["monster_count"] = 0 + else: + if floor_monsters_max >= 0 and floor_map.procgen_cache["monster_count"] >= floor_monsters_max: + return #cap the monsters total #There can only be one if "gobbo_red" not in floor_map.procgen_cache: floor_map.procgen_cache["gobbo_red"] = False + monster_count = random.randint(0, room_monsters_max) + for i in range(monster_count): #admittedly weird layout here, because player isn't in the entities list yet x, y = floor_map.player.x, floor_map.player.y @@ -65,13 +71,32 @@ def spawn_monsters(floor_map: FloorMap, room: RectangularRoom, room_monsters_max y = random.randint(room.y1 + 1, room.y2 - 1) #if there's no entity at that position - if not any(entity.x == x and entity.y == y for entity in floor_map.entities): + if not any(entity.x == x and entity.y == y and not entity.walkable for entity in floor_map.entities): #there's never more than one red gobbo, but there can be none at all if not floor_map.procgen_cache["gobbo_red"] and random.random() < 0.2: floor_map.procgen_cache["gobbo_red"] = True entity_types.gobbo_red.spawn(x, y, floor_map) else: entity_types.gobbo.spawn(x, y, floor_map) + floor_map.procgen_cache["monster_count"] += 1 + +def spawn_items(floor_map: FloorMap, room: RectangularRoom, room_items_max: int, floor_items_max: int) -> None: + item_count = random.randint(0, room_items_max) + + if "item_count" not in floor_map.procgen_cache: + floor_map.procgen_cache["item_count"] = 0 + else: + if floor_items_max >= 0 and floor_map.procgen_cache["item_count"] >= floor_items_max: + return #cap the item total + + for i in range(item_count): + x = random.randint(room.x1 + 1, room.x2 - 1) + y = random.randint(room.y1 + 1, room.y2 - 1) + + #if there's no entity at that position (not really needed for walkable entities) + if not any(entity.x == x and entity.y == y for entity in floor_map.entities): + entity_types.potion_of_healing.spawn(x, y, floor_map) + floor_map.procgen_cache["item_count"] += 1 #generators def generate_floor_map( @@ -82,9 +107,12 @@ def generate_floor_map( room_width_min: int = 6, room_height_min: int = 6, room_count_max: int = 20, - room_monsters_max: int = 2 + room_monsters_max: int = 2, + room_items_max: int = 1, + floor_monsters_max: int = -1, + floor_items_max: int = -1, ) -> FloorMap: - #simplistic floor generator + #simplistic floor generator - it'll get rewritten eventually floor_map: FloorMap = FloorMap(map_width, map_height) rooms: List[RectangularRoom] = [] @@ -110,7 +138,9 @@ def generate_floor_map( for x, y in make_corridor(rooms[-1].center, new_room.center): floor_map.tiles[x, y] = tile_types.floor - spawn_monsters(floor_map, new_room, room_monsters_max) + spawn_monsters(floor_map, new_room, room_monsters_max, floor_monsters_max) + + spawn_items(floor_map, new_room, room_items_max, floor_items_max) rooms.append(new_room) diff --git a/source/stats.py b/source/stats.py index 021ba15..36b9f08 100644 --- a/source/stats.py +++ b/source/stats.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import colors from event_handlers import GameOverHandler -from useable import BaseUseable +from useable import Unuseable if TYPE_CHECKING: from engine import Engine @@ -47,9 +47,10 @@ class Stats: engine.message_log.add_message(f"The {self.entity.name} died", colors.yellow) #transform into a dead body + #TODO: dummied in a "usable" to let dead objects be treated like items self.entity.char = "%" self.entity.color = (191, 0, 0) self.entity.walkable = True self.entity.ai = None #TODO: Could decay over time - self.entity.useable = BaseUseable() #TODO: dummied in a "usable" to let dead objects be treated like items + self.entity.useable = Unuseable() self.entity.name = f"Dead {self.entity.name}" diff --git a/source/useable.py b/source/useable.py index cb8bb5b..07fe50f 100644 --- a/source/useable.py +++ b/source/useable.py @@ -1,31 +1,97 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from actions import BaseAction +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from stats import Stats class BaseUseable: """Base type for useable items, with various utilities""" + current_stack: int + maximum_stack: int + consumable: bool - def apply(self, stats: Stats) -> BaseAction: - """Use this item's effects""" + def __init__(self, *, current_stack: int = 1, maximum_stack: int = -1, consumable: bool = False): + self.current_stack = current_stack + self.maximum_stack = maximum_stack + self.consumable = consumable + + def apply(self, stats: Stats) -> bool: + """ + Use this item's effects. + + Returns `True` if the item's state changed. + """ raise NotImplementedError() + def get_used_msg(self, appearance: str) -> Optional[str]: + """ + May return a string to display to the user. -class PotionOfHealing(BaseUseable): #TODO: Finish the potion of healing - """Restore a specified amount of health to the given Stats object""" - amount: int + `appearance` is what the item looks like, and can be substituted into the result. + """ + return None #default + + #utils + def reduce_stack(self, amount: int = 1) -> bool: + """ + Reduce the size of a stack by an amount. - def __init__(self, amount: int): - self.amount = amount + Returns `True` if this item should be deleted. + """ + if self.maximum_stack > 0: + self.current_stack -= amount + return self.current_stack <= 0 + return self.consumable + + def is_stack_empty(self) -> bool: + return self.consumable and self.maximum_stack > 0 and self.current_stack <= 0 + + def is_stack_mergable(self, other: BaseUseable) -> bool: + """ + If this returns `True`, this instance can be merged with the other instance. + """ + if self.__class__ is not other.__class__: + return False + + max_stack = max(self.maximum_stack, other.maximum_stack) + return self.current_stack + other.current_stack <= max_stack + + +class Unuseable(BaseUseable): + """A placeholder Useable for dead entities.""" + + def __init__(self): + super().__init__() #enforce defaults + + def apply(self, stats: Stats) -> bool: + return None + + def get_used_msg(self, appearance: str) -> Optional[str]: + return f"This {appearance} is utterly useless." + + +class PotionOfHealing(BaseUseable): + """Restores 4d4 health when applied.""" + __last_roll: int = -1 + + def apply(self, stats: Stats) -> bool: + self.__last_roll = roll_dice(4, 4) + stats.current_hp += self.__last_roll + return self.reduce_stack() + + def get_used_msg(self, appearance: str) -> Optional[str]: + return f"You restored {self.__last_roll} health." - def apply(self, stats: Stats) -> BaseAction: - """Use this item's effects""" - raise NotImplementedError() # NOTE: NetHack's version # Healing: 8d4 | 6d4 | 4d4. If the result is above MaxHP, MaxHP is incrased by 1 | 1 | 0. # Extra Healing: 8d8 | 6d8 | 4d8. If the result is above MaxHP, MaxHP is incrased by 5 | 2 | 0. # Full Healing: 400 | 400 | 400. If the result is above MaxHP, MaxHP is incrased by 8 | 4 | 0. + +#TODO: move this into a different file +import random +def roll_dice(number: int, sides: int) -> int: + total: int = 0 + for i in range(number): + total += random.randint(1, sides) + return total