Items can be picked up and stored in the inventory

When more than one item can be picked up, and options window is shown.

Stubs for "using" an item are in place.
This commit is contained in:
Kayne Ruse 2025-03-29 12:51:09 +11:00
parent e055ea1f5d
commit f831019148
9 changed files with 244 additions and 33 deletions

View File

@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import List, TYPE_CHECKING
import colors
from floor_map import FloorMap from floor_map import FloorMap
@ -15,7 +13,7 @@ class BaseAction:
def __init__(self, entity): def __init__(self, entity):
self.entity = entity self.entity = entity
def apply(self) -> bool: def perform(self) -> bool:
"""return True if the game state should be progressed""" """return True if the game state should be progressed"""
raise NotImplementedError() raise NotImplementedError()
@ -25,12 +23,12 @@ class QuitAction(BaseAction):
"""override the base __init__, as no entity is needed""" """override the base __init__, as no entity is needed"""
pass pass
def apply(self) -> bool: def perform(self) -> bool:
raise SystemExit() raise SystemExit()
class WaitAction(BaseAction): class WaitAction(BaseAction):
def apply(self) -> bool: def perform(self) -> bool:
return True return True
@ -41,7 +39,7 @@ class MovementAction(BaseAction):
self.xdir = xdir self.xdir = xdir
self.ydir = ydir self.ydir = ydir
def apply(self) -> bool: def perform(self) -> bool:
dest_x = self.entity.x + self.xdir dest_x = self.entity.x + self.xdir
dest_y = self.entity.y + self.ydir dest_y = self.entity.y + self.ydir
@ -52,7 +50,7 @@ class MovementAction(BaseAction):
return False return False
if not floor_map.tiles["walkable"][dest_x, dest_y]: if not floor_map.tiles["walkable"][dest_x, dest_y]:
return False 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 return False
self.entity.set_pos(dest_x, dest_y) self.entity.set_pos(dest_x, dest_y)
@ -65,11 +63,16 @@ class MeleeAction(BaseAction):
self.xdir = xdir self.xdir = xdir
self.ydir = ydir self.ydir = ydir
def apply(self) -> bool: def perform(self) -> bool:
dest_x = self.entity.x + self.xdir dest_x = self.entity.x + self.xdir
dest_y = self.entity.y + self.ydir 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: if not target or not target.stats:
return False return False
@ -102,11 +105,42 @@ class BumpAction(BaseAction):
self.xdir = xdir self.xdir = xdir
self.ydir = ydir self.ydir = ydir
def apply(self) -> bool: def perform(self) -> bool:
dest_x = self.entity.x + self.xdir dest_x = self.entity.x + self.xdir
dest_y = self.entity.y + self.ydir dest_y = self.entity.y + self.ydir
if self.entity.floor_map.get_entity_at(dest_x, dest_y, unwalkable_only=True): if self.entity.floor_map.get_all_entities_at(dest_x, dest_y, unwalkable_only=True):
return MeleeAction(self.entity, self.xdir, self.ydir).apply() return MeleeAction(self.entity, self.xdir, self.ydir).perform()
else: 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

View File

@ -8,6 +8,7 @@ from tcod.map import compute_fov
from entity import Entity from entity import Entity
from message_log import Message, MessageLog from message_log import Message, MessageLog
from floor_map import FloorMap #TODO: replace with "DungeonMap" or similar 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 from render_functions import render_hp_bar, render_names_at
@ -55,13 +56,17 @@ class Engine:
Processes monster AI and other things. Processes monster AI and other things.
Returns `True` if the game state should be progressed. Returns `True` if the game state should be progressed.
""" """
result = False actions: List[BaseAction] = []
#make the entities think and act #make the entities think and act
for entity in set(self.floor_map.entities) - {self.player}: for entity in set(self.floor_map.entities) - {self.player}:
if entity.ai: if entity.ai:
action = entity.ai.process() actions.append(entity.ai.process())
result |= action.apply()
result = False
for action in actions:
result |= action.perform()
return result return result

View File

@ -3,10 +3,16 @@ from __future__ import annotations
import copy import copy
from typing import Optional, Tuple, Type from typing import Optional, Tuple, Type
from ai import BaseAI
from stats import Stats from stats import Stats
from ai import BaseAI
from useable import BaseUseable
from inventory import Inventory
class Entity: class Entity:
stats: Stats
ai: BaseAI
useable: BaseUseable
def __init__( def __init__(
self, self,
x: int = 0, x: int = 0,
@ -20,6 +26,10 @@ class Entity:
#monster-specific stuff #monster-specific stuff
ai_class: Type[BaseAI] = None, ai_class: Type[BaseAI] = None,
stats: Stats = None, stats: Stats = None,
#item-specific stuff
useable: BaseUseable = None,
inventory: Inventory = None,
): ):
self.x = x self.x = x
self.y = y self.y = y
@ -33,10 +43,13 @@ class Entity:
if ai_class: if ai_class:
self.ai: Optional[BaseAI] = ai_class(self) self.ai: Optional[BaseAI] = ai_class(self)
if stats:
self.stats = stats self.stats = stats
self.stats.entity = self self.stats.entity = self
#item-specific stuff
self.useable = useable
self.inventory = inventory
#generic entity stuff #generic entity stuff
def spawn(self, x: int, y: int, floor_map): def spawn(self, x: int, y: int, floor_map):
clone = copy.deepcopy(self) clone = copy.deepcopy(self)
@ -53,3 +66,7 @@ class Entity:
#monster-specific stuff #monster-specific stuff
def is_alive(self) -> bool: def is_alive(self) -> bool:
return bool(self.ai) return bool(self.ai)
#item-specific stuff
def is_item(self) -> bool:
return bool(self.useable)

View File

@ -1,6 +1,7 @@
from entity import Entity 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
player = Entity( player = Entity(
char = "@", char = "@",
@ -9,6 +10,7 @@ player = Entity(
walkable = False, walkable = False,
ai_class = BaseAI, #TODO: remove this or dummy it out ai_class = BaseAI, #TODO: remove this or dummy it out
stats = Stats(hp = 10, attack = 2, defense = 1), stats = Stats(hp = 10, attack = 2, defense = 1),
inventory=Inventory(),
) )
#gobbos #gobbos

View File

@ -1,10 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
import tcod import tcod
import colors import colors
from actions import BaseAction, QuitAction, BumpAction, WaitAction from actions import (
BaseAction,
QuitAction,
BumpAction,
WaitAction,
PickupAction,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from engine import Engine from engine import Engine
@ -51,6 +57,10 @@ WAIT_KEYS = {
tcod.event.KeySym.CLEAR, tcod.event.KeySym.CLEAR,
} }
PICKUP_KEYS = {
tcod.event.KeySym.COMMA,
}
CURSOR_SCROLL_KEYS = { CURSOR_SCROLL_KEYS = {
tcod.event.KeySym.UP: -1, tcod.event.KeySym.UP: -1,
tcod.event.KeySym.DOWN: 1, tcod.event.KeySym.DOWN: 1,
@ -61,6 +71,11 @@ CURSOR_SCROLL_KEYS = {
tcod.event.KeySym.KP_8: -1, 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 #the event handlers are one part of the engine
class EventHandler(tcod.event.EventDispatch[BaseAction]): class EventHandler(tcod.event.EventDispatch[BaseAction]):
engine: Engine engine: Engine
@ -87,7 +102,7 @@ class EventHandler(tcod.event.EventDispatch[BaseAction]):
if action is None: if action is None:
continue continue
result |= action.apply() result |= action.perform()
return result return result
@ -109,9 +124,18 @@ class GameplayHandler(EventHandler):
if key in WAIT_KEYS: if key in WAIT_KEYS:
return WaitAction(player) 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 if key == tcod.event.KeySym.BACKQUOTE: #lowercase tilde
self.engine.event_handler = LogHistoryViewer(self.engine, self) 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: def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
if self.engine.floor_map.in_bounds(event.tile.x, event.tile.y): if self.engine.floor_map.in_bounds(event.tile.x, event.tile.y):
self.engine.mouse_location = event.tile.x, event.tile.y self.engine.mouse_location = event.tile.x, event.tile.y
@ -175,7 +199,7 @@ class LogHistoryViewer(EventHandler):
elif adjust > 0 and self.cursor == self.log_length - 1: elif adjust > 0 and self.cursor == self.log_length - 1:
pass #do nothing pass #do nothing
else: 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: elif event.sym == tcod.event.KeySym.HOME:
self.cursor = 0 self.cursor = 0
@ -184,3 +208,71 @@ class LogHistoryViewer(EventHandler):
else: else:
#return to the game #return to the game
self.engine.event_handler = self.baseEventHandler 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

View File

@ -1,5 +1,5 @@
from __future__ import annotations 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 import numpy as np
from tcod.console import Console from tcod.console import Console
@ -33,16 +33,18 @@ class FloorMap:
def in_bounds(self, x: int, y: int) -> bool: def in_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.width and 0 <= y < self.height 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: for entity in self.entities:
if entity.x == x and entity.y == y: if entity.x == x and entity.y == y:
if unwalkable_only: if unwalkable_only and entity.walkable:
if not entity.walkable: continue
return entity if items_only and not entity.is_item():
else: continue
return entity result.append(entity)
return None return result
def render(self, console: Console) -> None: def render(self, console: Console) -> None:
console.rgb[0:self.width, 0:self.height] = np.select( console.rgb[0:self.width, 0:self.height] = np.select(

31
source/inventory.py Normal file
View File

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

View File

@ -4,6 +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
if TYPE_CHECKING: if TYPE_CHECKING:
from engine import Engine from engine import Engine
@ -52,4 +53,5 @@ class Stats:
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(self.entity) #TMP
self.entity.name = f"Dead {self.entity.name}" self.entity.name = f"Dead {self.entity.name}"

26
source/useable.py Normal file
View File

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