Skip to content

Commit 3aa1220

Browse files
committed
Part 8: Items and Inventory (with entity parent system refactor and Part 10 refactoring)
1 parent fad1f44 commit 3aa1220

File tree

13 files changed

+520
-18
lines changed

13 files changed

+520
-18
lines changed

game/actions.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

3-
from typing import Optional, TYPE_CHECKING
3+
from typing import Optional, Tuple, TYPE_CHECKING
44

55
import game.color
6+
import game.exceptions
67

78
if TYPE_CHECKING:
89
import game.engine
910
import game.entity
11+
import game.components.inventory
1012

1113

1214
class Action:
@@ -113,3 +115,60 @@ def perform(self) -> None:
113115
return MeleeAction(self.entity, self.dx, self.dy).perform()
114116
else:
115117
return MovementAction(self.entity, self.dx, self.dy).perform()
118+
119+
120+
class PickupAction(Action):
121+
"""Pickup an item and add it to the inventory, if there is room for it."""
122+
123+
def __init__(self, entity: game.entity.Actor):
124+
super().__init__(entity)
125+
126+
def perform(self) -> None:
127+
# Type check to ensure entity is an Actor with inventory
128+
assert isinstance(self.entity, game.entity.Actor), "Entity must be an Actor for inventory access"
129+
130+
actor_location_x = self.entity.x
131+
actor_location_y = self.entity.y
132+
inventory = self.entity.inventory
133+
134+
for item in self.engine.game_map.items:
135+
if actor_location_x == item.x and actor_location_y == item.y:
136+
if len(inventory.items) >= inventory.capacity:
137+
raise game.exceptions.Impossible("Your inventory is full.")
138+
139+
self.engine.game_map.entities.remove(item)
140+
item.parent = inventory
141+
inventory.items.append(item)
142+
143+
self.engine.message_log.add_message(f"You picked up the {item.name}!")
144+
return
145+
146+
raise game.exceptions.Impossible("There is nothing here to pick up.")
147+
148+
149+
class ItemAction(Action):
150+
def __init__(self, entity: game.entity.Actor, item: game.entity.Item, target_xy: Optional[Tuple[int, int]] = None):
151+
super().__init__(entity)
152+
self.item = item
153+
if not target_xy:
154+
target_xy = entity.x, entity.y
155+
self.target_xy = target_xy
156+
157+
@property
158+
def target_actor(self) -> Optional[game.entity.Actor]:
159+
"""Return the actor at this actions destination."""
160+
return self.engine.game_map.get_actor_at_location(*self.target_xy)
161+
162+
def perform(self) -> None:
163+
"""Invoke the items ability, this action will be given to provide context."""
164+
if self.item.consumable is not None:
165+
self.item.consumable.activate(self)
166+
else:
167+
raise game.exceptions.Impossible("This item cannot be used.")
168+
169+
170+
class DropItem(ItemAction):
171+
def perform(self) -> None:
172+
# Type check to ensure entity is an Actor with inventory
173+
assert isinstance(self.entity, game.entity.Actor), "Entity must be an Actor for inventory access"
174+
self.entity.inventory.drop(self.item)

game/color.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
player_die = (0xFF, 0x30, 0x30)
77
enemy_die = (0xFF, 0xA0, 0x30)
88

9+
impossible = (0x80, 0x80, 0x80)
10+
invalid = (0xFF, 0xFF, 0x00)
11+
912
welcome_text = (0x20, 0xA0, 0xFF)
13+
health_recovered = (0x0, 0xFF, 0x0)
1014

1115
bar_text = white
1216
bar_filled = (0x0, 0x60, 0x0)

game/components/consumable.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional, TYPE_CHECKING
4+
5+
import game.actions
6+
import game.color
7+
import game.components.base_component
8+
import game.exceptions
9+
10+
if TYPE_CHECKING:
11+
import game.entity
12+
13+
14+
class Consumable(game.components.base_component.BaseComponent):
15+
parent: game.entity.Item
16+
17+
def get_action(self, consumer: game.entity.Actor) -> Optional[game.actions.Action]:
18+
"""Try to return the action for this item."""
19+
return game.actions.ItemAction(consumer, self.parent)
20+
21+
def activate(self, action: game.actions.ItemAction) -> None:
22+
"""Invoke this items ability.
23+
24+
`action` is the context for this activation.
25+
"""
26+
raise NotImplementedError()
27+
28+
def consume(self) -> None:
29+
"""Remove the consumed item from its containing inventory."""
30+
entity = self.parent
31+
inventory = entity.parent
32+
if isinstance(inventory, game.components.inventory.Inventory):
33+
inventory.items.remove(entity)
34+
35+
36+
class HealingConsumable(Consumable):
37+
def __init__(self, amount: int):
38+
self.amount = amount
39+
40+
def activate(self, action: game.actions.ItemAction) -> None:
41+
# Type check to ensure consumer is an Actor
42+
assert isinstance(action.entity, game.entity.Actor), "Consumer must be an Actor"
43+
consumer = action.entity
44+
amount_recovered = consumer.fighter.heal(self.amount)
45+
46+
if amount_recovered > 0:
47+
self.engine.message_log.add_message(
48+
f"You consume the {self.parent.name}, and recover {amount_recovered} HP!",
49+
game.color.health_recovered,
50+
)
51+
self.consume()
52+
else:
53+
raise game.exceptions.Impossible("Your health is already full.")

game/components/fighter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,21 @@ def die(self) -> None:
4848
self.parent.render_order = game.render_order.RenderOrder.CORPSE
4949

5050
self.engine.message_log.add_message(death_message, death_message_color)
51+
52+
def heal(self, amount: int) -> int:
53+
if self.hp == self.max_hp:
54+
return 0
55+
56+
new_hp_value = self.hp + amount
57+
58+
if new_hp_value > self.max_hp:
59+
new_hp_value = self.max_hp
60+
61+
amount_recovered = new_hp_value - self.hp
62+
63+
self.hp = new_hp_value
64+
65+
return amount_recovered
66+
67+
def take_damage(self, amount: int) -> None:
68+
self.hp -= amount

game/components/inventory.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from typing import List, TYPE_CHECKING
4+
5+
import game.components.base_component
6+
7+
if TYPE_CHECKING:
8+
import game.entity
9+
import game.game_map
10+
11+
12+
class Inventory(game.components.base_component.BaseComponent):
13+
parent: game.entity.Actor
14+
15+
def __init__(self, capacity: int):
16+
self.capacity = capacity
17+
self.items: List[game.entity.Item] = []
18+
19+
@property
20+
def gamemap(self) -> game.game_map.GameMap:
21+
return self.parent.gamemap
22+
23+
def drop(self, item: game.entity.Item) -> None:
24+
"""
25+
Removes an item from the inventory and restores it to the game map, at the player's current location.
26+
"""
27+
self.items.remove(item)
28+
item.place(self.parent.x, self.parent.y, self.gamemap)
29+
30+
self.engine.message_log.add_message(f"You dropped the {item.name}.")

game/entity.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
from __future__ import annotations
22

3-
from typing import Optional, Tuple, Type, TYPE_CHECKING
3+
from typing import Optional, Tuple, Type, Union, TYPE_CHECKING
44

55
import game.render_order
66

77
if TYPE_CHECKING:
88
import game.components.ai
9+
import game.components.consumable
910
import game.components.fighter
10-
import game.game_map
11+
import game.components.inventory
12+
13+
import game.game_map
1114

1215

1316
class Entity:
1417
"""
1518
A generic object to represent players, enemies, items, etc.
1619
"""
1720

18-
# Part 8 refactoring prep: Will become Union[GameMap, Inventory] in Part 8
19-
gamemap: Optional[game.game_map.GameMap]
21+
parent: Union[game.game_map.GameMap, game.components.inventory.Inventory]
2022

2123
def __init__(
2224
self,
23-
gamemap: Optional[game.game_map.GameMap] = None,
25+
parent: Optional[Union[game.game_map.GameMap, game.components.inventory.Inventory]] = None,
2426
x: int = 0,
2527
y: int = 0,
2628
char: str = "?",
@@ -35,19 +37,28 @@ def __init__(
3537
self.name = name
3638
self.blocks_movement = blocks_movement
3739
self.render_order = game.render_order.RenderOrder.CORPSE
38-
if gamemap:
39-
# If gamemap isn't provided now then it will be set later.
40-
self.gamemap = gamemap
41-
gamemap.entities.add(self)
40+
if parent:
41+
# If parent isn't provided now then it will be set later.
42+
self.parent = parent
43+
if hasattr(parent, 'entities'):
44+
parent.entities.add(self)
45+
46+
@property
47+
def gamemap(self) -> game.game_map.GameMap:
48+
if isinstance(self.parent, game.game_map.GameMap):
49+
return self.parent
50+
else:
51+
return self.parent.gamemap
4252

4353
def place(self, x: int, y: int, gamemap: Optional[game.game_map.GameMap] = None) -> None:
4454
"""Place this entity at a new location. Handles moving across GameMaps."""
4555
self.x = x
4656
self.y = y
4757
if gamemap:
48-
if hasattr(self, "gamemap") and self.gamemap is not None: # Possibly uninitialized.
49-
self.gamemap.entities.remove(self)
50-
self.gamemap = gamemap
58+
if hasattr(self, "parent"): # Possibly uninitialized.
59+
if hasattr(self.parent, "entities"):
60+
self.parent.entities.remove(self)
61+
self.parent = gamemap
5162
gamemap.entities.add(self)
5263

5364
def move(self, dx: int, dy: int) -> None:
@@ -67,9 +78,10 @@ def __init__(
6778
name: str = "<Unnamed>",
6879
ai_cls: Type[game.components.ai.BaseAI],
6980
fighter: game.components.fighter.Fighter,
81+
inventory: game.components.inventory.Inventory,
7082
):
7183
super().__init__(
72-
gamemap=None,
84+
parent=None,
7385
x=x,
7486
y=y,
7587
char=char,
@@ -83,9 +95,41 @@ def __init__(
8395
self.fighter = fighter
8496
self.fighter.parent = self
8597

98+
self.inventory = inventory
99+
self.inventory.parent = self
100+
86101
self.render_order = game.render_order.RenderOrder.ACTOR
87102

88103
@property
89104
def is_alive(self) -> bool:
90105
"""Returns True as long as this actor can perform actions."""
91106
return bool(self.ai)
107+
108+
109+
class Item(Entity):
110+
def __init__(
111+
self,
112+
*,
113+
x: int = 0,
114+
y: int = 0,
115+
char: str = "?",
116+
color: Tuple[int, int, int] = (255, 255, 255),
117+
name: str = "<Unnamed>",
118+
consumable: Optional[game.components.consumable.Consumable] = None,
119+
):
120+
super().__init__(
121+
parent=None,
122+
x=x,
123+
y=y,
124+
char=char,
125+
color=color,
126+
name=name,
127+
blocks_movement=False,
128+
)
129+
130+
self.consumable = consumable
131+
132+
if self.consumable:
133+
self.consumable.parent = self
134+
135+
self.render_order = game.render_order.RenderOrder.ITEM

game/entity_factories.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from game.components.ai import HostileEnemy
2+
from game.components.consumable import HealingConsumable
23
from game.components.fighter import Fighter
3-
from game.entity import Actor
4+
from game.components.inventory import Inventory
5+
from game.entity import Actor, Item
46

57
player = Actor(
68
char="@",
79
color=(255, 255, 255),
810
name="Player",
911
ai_cls=HostileEnemy,
1012
fighter=Fighter(hp=30, defense=2, power=5),
13+
inventory=Inventory(capacity=26),
1114
)
1215

1316
orc = Actor(
@@ -16,6 +19,7 @@
1619
name="Orc",
1720
ai_cls=HostileEnemy,
1821
fighter=Fighter(hp=10, defense=0, power=3),
22+
inventory=Inventory(capacity=0),
1923
)
2024

2125
troll = Actor(
@@ -24,4 +28,12 @@
2428
name="Troll",
2529
ai_cls=HostileEnemy,
2630
fighter=Fighter(hp=16, defense=1, power=4),
31+
inventory=Inventory(capacity=0),
32+
)
33+
34+
health_potion = Item(
35+
char="!",
36+
color=(127, 0, 255),
37+
name="Health Potion",
38+
consumable=HealingConsumable(amount=4),
2739
)

game/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Impossible(Exception):
2+
"""Exception raised when an action is impossible to be performed.
3+
4+
The reason is given as the exception message.
5+
"""

game/game_map.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def actors(self) -> Iterator[game.entity.Actor]:
3232
"""Iterate over this maps living actors."""
3333
yield from (entity for entity in self.entities if isinstance(entity, game.entity.Actor) and entity.is_alive)
3434

35+
@property
36+
def items(self) -> Iterator[game.entity.Item]:
37+
yield from (entity for entity in self.entities if isinstance(entity, game.entity.Item))
38+
3539
def get_blocking_entity_at_location(
3640
self,
3741
location_x: int,
@@ -47,6 +51,13 @@ def get_blocking_entity_at(self, x: int, y: int) -> Optional[game.entity.Entity]
4751
"""Alias for get_blocking_entity_at_location"""
4852
return self.get_blocking_entity_at_location(x, y)
4953

54+
def get_actor_at_location(self, x: int, y: int) -> Optional[game.entity.Actor]:
55+
for actor in self.actors:
56+
if actor.x == x and actor.y == y:
57+
return actor
58+
59+
return None
60+
5061
def in_bounds(self, x: int, y: int) -> bool:
5162
"""Return True if x and y are inside of the bounds of this map."""
5263
return 0 <= x < self.width and 0 <= y < self.height

0 commit comments

Comments
 (0)