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

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

@ -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
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),
)

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

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

@ -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= [

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

@ -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}"

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