Implemented usable potions, stackable items

This commit is contained in:
Kayne Ruse 2025-04-03 16:56:59 +11:00
parent e4a99900b5
commit a4c112ce7b
8 changed files with 297 additions and 43 deletions

View File

@ -4,6 +4,7 @@ from typing import List, Optional, TYPE_CHECKING
import colors import colors
from floor_map import FloorMap from floor_map import FloorMap
from inventory import Inventory from inventory import Inventory
from useable import BaseUseable
if TYPE_CHECKING: if TYPE_CHECKING:
from engine import Engine from engine import Engine
@ -132,9 +133,13 @@ class PickupAction(BaseAction):
if len(item_stack) == 0: if len(item_stack) == 0:
return False return False
elif len(item_stack) == 1: 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]) floor_map.entities.remove(item_stack[0])
self.entity.inventory.insert(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: else:
from event_handlers import OptionSelector #circular imports are a pain from event_handlers import OptionSelector #circular imports are a pain
@ -155,18 +160,24 @@ class PickupAction(BaseAction):
#utils #utils
def pickup_callback(self, engine: Engine, floor_map: FloorMap, entity: Entity, item: Entity) -> None: 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) floor_map.entities.remove(item)
entity.inventory.insert(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): class DropAction(BaseAction):
"""Drop an item from an entity's inventory at the entity's location""" """Drop an item from an entity's inventory at the entity's location"""
index: int 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__""" """override the base __init__"""
super().__init__(entity) super().__init__(entity)
self.index = index self.index = index
self.display_callback = display_callback
def perform(self) -> bool: def perform(self) -> bool:
x = self.entity.x x = self.entity.x
@ -183,6 +194,81 @@ class DropAction(BaseAction):
floor_map.entities.add(item) 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) engine.message_log.add_message(f"you dropped a(n) {item.name}", color=colors.terminal_light)
return True 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

View File

@ -2,6 +2,7 @@ from entity import Entity
from ai import BaseAI, AggressiveWhenSeen from ai import BaseAI, AggressiveWhenSeen
from stats import Stats from stats import Stats
from inventory import Inventory from inventory import Inventory
from useable import PotionOfHealing
#player and utils #player and utils
player = Entity( player = Entity(
@ -30,8 +31,15 @@ gobbo_red = Entity(
name = "Red Gobbo", name = "Red Gobbo",
walkable = False, walkable = False,
ai_class = AggressiveWhenSeen, 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 #items - conumables
#TODO: potion of healing entity 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),
)

View File

@ -11,6 +11,8 @@ from actions import (
WaitAction, WaitAction,
PickupAction, PickupAction,
DropAction, DropAction,
DropPartialStackAction,
UsageAction,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -245,9 +247,12 @@ class InventoryViewer(EventHandler):
#render the cursor & inventory contents #render the cursor & inventory contents
offset: int = 0 offset: int = 0
for item in self.entity.inventory.contents: 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( inner_console.print(
4, 2 + offset, 4, 2 + offset,
string = item.name, string = msg,
fg=colors.terminal_light, bg=colors.black, fg=colors.terminal_light, bg=colors.black,
) )
offset += 1 offset += 1
@ -280,10 +285,20 @@ class InventoryViewer(EventHandler):
#drop an item form this entity's inventory #drop an item form this entity's inventory
item: Entity = self.entity.inventory.access(self.cursor) 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, self.engine.event_handler = OptionSelector(self.engine, self,
title=f"Drop The {item.name}?", title=item.name,
options=["Yes", "No"], options=options,
callback=lambda x: self.selector_callback(x) callback=callback
) )
#TODO: hotkeys via a config #TODO: hotkeys via a config
@ -297,23 +312,50 @@ class InventoryViewer(EventHandler):
self.engine.event_handler = self.parent_handler self.engine.event_handler = self.parent_handler
#utils #utils
def selector_callback(self, answer: int) -> Optional[BaseAction]: def default_selector_callback(self, selected: int) -> Optional[BaseAction]:
#TODO: insert a sub-selection box to choose what to do with this item if selected == 0: #Use
if answer == 0: #TODO: Use, Drop, Back return self.use()
elif selected == 1: #Drop
return self.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]: def drop(self) -> Optional[BaseAction]:
"""Drop the item at the cursor's position, and adjust the cursor if needed.""" """Drop the item at the cursor's position, and adjust the cursor if needed."""
if self.length > 0: if self.length > 0:
index = self.cursor index = self.cursor
#bounds return DropAction(self.entity, index, lambda x: self.adjust_length(x))
self.length -= 1
def adjust_length(self, amount: int):
self.length += amount
if self.cursor >= self.length: if self.cursor >= self.length:
self.cursor = self.length - 1 self.cursor = self.length - 1
return DropAction(self.entity, index)
#generic tools #generic tools
class OptionSelector(EventHandler): class OptionSelector(EventHandler):

View File

@ -11,11 +11,16 @@ class Inventory:
def __init__(self, contents: List[Entity] = []): def __init__(self, contents: List[Entity] = []):
self._contents = contents self._contents = contents
def insert(self, entity: Entity) -> bool: def insert(self, item: Entity) -> bool:
if entity in self._contents: if item in self._contents:
return False 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 return True
def access(self, index: int) -> Optional[Entity]: def access(self, index: int) -> Optional[Entity]:
@ -30,8 +35,24 @@ class Inventory:
else: else:
return self._contents.pop(index) 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 @property
def contents(self) -> List[Entity]: def contents(self) -> List[Entity]:
return self._contents 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 #TODO: items need a weight, inventory needs a max capacity

View File

@ -30,7 +30,7 @@ def main() -> None:
engine = Engine( engine = Engine(
#is created externally, because #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, ui_height = ui_height,
initial_log= [ initial_log= [

View File

@ -50,13 +50,19 @@ class RectangularRoom:
self.y2 >= other.y1 self.y2 >= other.y1
) )
def spawn_monsters(floor_map: FloorMap, room: RectangularRoom, room_monsters_max: int) -> None: def spawn_monsters(floor_map: FloorMap, room: RectangularRoom, room_monsters_max: int, floor_monsters_max: int) -> None:
monster_count = random.randint(0, room_monsters_max) 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 #There can only be one
if "gobbo_red" not in floor_map.procgen_cache: if "gobbo_red" not in floor_map.procgen_cache:
floor_map.procgen_cache["gobbo_red"] = False floor_map.procgen_cache["gobbo_red"] = False
monster_count = random.randint(0, room_monsters_max)
for i in range(monster_count): for i in range(monster_count):
#admittedly weird layout here, because player isn't in the entities list yet #admittedly weird layout here, because player isn't in the entities list yet
x, y = floor_map.player.x, floor_map.player.y 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) y = random.randint(room.y1 + 1, room.y2 - 1)
#if there's no entity at that position #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 #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: if not floor_map.procgen_cache["gobbo_red"] and random.random() < 0.2:
floor_map.procgen_cache["gobbo_red"] = True floor_map.procgen_cache["gobbo_red"] = True
entity_types.gobbo_red.spawn(x, y, floor_map) entity_types.gobbo_red.spawn(x, y, floor_map)
else: else:
entity_types.gobbo.spawn(x, y, floor_map) 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 #generators
def generate_floor_map( def generate_floor_map(
@ -82,9 +107,12 @@ def generate_floor_map(
room_width_min: int = 6, room_width_min: int = 6,
room_height_min: int = 6, room_height_min: int = 6,
room_count_max: int = 20, 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: ) -> FloorMap:
#simplistic floor generator #simplistic floor generator - it'll get rewritten eventually
floor_map: FloorMap = FloorMap(map_width, map_height) floor_map: FloorMap = FloorMap(map_width, map_height)
rooms: List[RectangularRoom] = [] rooms: List[RectangularRoom] = []
@ -110,7 +138,9 @@ def generate_floor_map(
for x, y in make_corridor(rooms[-1].center, new_room.center): for x, y in make_corridor(rooms[-1].center, new_room.center):
floor_map.tiles[x, y] = tile_types.floor 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) rooms.append(new_room)

View File

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
import colors import colors
from event_handlers import GameOverHandler from event_handlers import GameOverHandler
from useable import BaseUseable from useable import Unuseable
if TYPE_CHECKING: if TYPE_CHECKING:
from engine import Engine from engine import Engine
@ -47,9 +47,10 @@ class Stats:
engine.message_log.add_message(f"The {self.entity.name} died", colors.yellow) engine.message_log.add_message(f"The {self.entity.name} died", colors.yellow)
#transform into a dead body #transform into a dead body
#TODO: dummied in a "usable" to let dead objects be treated like items
self.entity.char = "%" self.entity.char = "%"
self.entity.color = (191, 0, 0) self.entity.color = (191, 0, 0)
self.entity.walkable = True self.entity.walkable = True
self.entity.ai = None #TODO: Could decay over time 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}" self.entity.name = f"Dead {self.entity.name}"

View File

@ -1,31 +1,97 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from actions import BaseAction
if TYPE_CHECKING: if TYPE_CHECKING:
from stats import Stats from stats import Stats
class BaseUseable: class BaseUseable:
"""Base type for useable items, with various utilities""" """Base type for useable items, with various utilities"""
current_stack: int
maximum_stack: int
consumable: bool
def apply(self, stats: Stats) -> BaseAction: def __init__(self, *, current_stack: int = 1, maximum_stack: int = -1, consumable: bool = False):
"""Use this item's effects""" 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() 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 `appearance` is what the item looks like, and can be substituted into the result.
"""Restore a specified amount of health to the given Stats object""" """
amount: int return None #default
def __init__(self, amount: int): #utils
self.amount = amount def reduce_stack(self, amount: int = 1) -> bool:
"""
Reduce the size of a stack by an 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 # NOTE: NetHack's version
# Healing: 8d4 | 6d4 | 4d4. If the result is above MaxHP, MaxHP is incrased by 1 | 1 | 0. # 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. # 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. # 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