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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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