1
0

Generate items and display on map, implement exception handling

This commit is contained in:
Timothy Warren 2022-01-12 16:12:07 -05:00
parent f947338c2d
commit 35862fcc19
11 changed files with 198 additions and 47 deletions

View File

@ -3,10 +3,11 @@ from __future__ import annotations
from typing import overload, Optional, Tuple, TYPE_CHECKING from typing import overload, Optional, Tuple, TYPE_CHECKING
import color import color
import exceptions
if TYPE_CHECKING: if TYPE_CHECKING:
from engine import Engine from engine import Engine
from entity import Actor, Entity from entity import Actor, Entity, Item
class Action: class Action:
@ -31,6 +32,29 @@ class Action:
""" """
class ItemAction(Action):
def __init__(
self,
entity: Actor,
item: Item,
target_xy: Optional[Tuple[int, int]] = None
):
super().__init__(entity)
self.item = item
if not target_xy:
target_xy = entity.x, entity.y
self.target_xy = target_xy
@property
def target_actor(self) -> Optional[Actor]:
"""Return the actor at this action's destination."""
return self.engine.game_map.get_actor_at_location(*self.target_xy)
def perform(self) -> None:
"""Invoke the item's ability, this action will be given to provide context."""
self.item.consumable.activate(self)
class EscapeAction(Action): class EscapeAction(Action):
def perform(self) -> None: def perform(self) -> None:
raise SystemExit() raise SystemExit()
@ -68,7 +92,7 @@ class MeleeAction(ActionWithDirection):
def perform(self) -> None: def perform(self) -> None:
target = self.target_actor target = self.target_actor
if not target: if not target:
return # No entity to attack. raise exceptions.Impossible("Nothing to attack.")
damage = self.entity.fighter.power - target.fighter.defense damage = self.entity.fighter.power - target.fighter.defense
@ -96,11 +120,14 @@ class MovementAction(ActionWithDirection):
dest_x, dest_y = self.dest_xy dest_x, dest_y = self.dest_xy
if not self.engine.game_map.in_bounds(dest_x, dest_y): if not self.engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds # Destination is out of bounds
raise exceptions.Impossible("That way is blocked.")
if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]: if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]:
return # Destination is blocked by a tile. # Destination is blocked by a tile.
raise exceptions.Impossible("That way is blocked.")
if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y): if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
return # Destination is blocked by an entity. # Destination is blocked by an entity.
raise exceptions.Impossible("That way is blocked.")
self.entity.move(self.dx, self.dy) self.entity.move(self.dx, self.dy)

View File

@ -7,7 +7,12 @@ enemy_atk = (0xFF, 0xC0, 0xC0)
player_die = (0xFF, 0x30, 0x30) player_die = (0xFF, 0x30, 0x30)
enemy_die = (0xFF, 0xA0, 0x30) enemy_die = (0xFF, 0xA0, 0x30)
invalid = (0xFF, 0xFF, 0x00)
impossible = (0x80, 0x80, 0x80)
error = (0xFF, 0x40, 0x40)
welcome_text = (0x20, 0xA0, 0xFF) welcome_text = (0x20, 0xA0, 0xFF)
health_recovered = (0x0, 0xFF, 0x0)
bar_text = white bar_text = white
bar_filled = (0x0, 0x60, 0x0) bar_filled = (0x0, 0x60, 0x0)

43
components/consumable.py Normal file
View File

@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
import actions
import color
from components.base_component import BaseComponent
from exceptions import Impossible
if TYPE_CHECKING:
from entity import Actor, Item
class Consumable(BaseComponent):
parent: Item
def get_action(self, consumer: Actor) -> Optional[actions.Action]:
"""Try to return the action for this item."""
return actions.ItemAction(consumer, self.parent)
def activate(self, action: actions.ItemAction) -> None:
"""Invoke this item's ability.
`action` is the context for this activation.
"""
raise NotImplementedError()
class HealingConsumable(Consumable):
def __init__(self, amount: int):
self.amount = amount
def activate(self, action: actions.ItemAction) -> None:
consumer = action.entity
amount_recovered = consumer.fighter.heal(self.amount)
if amount_recovered > 0:
self.engine.message_log.add_message(
f"You consume the {self.parent.name}, and recover {amount_recovered} HP!",
color.health_recovered
)
else:
raise Impossible(f"Your health is already full.")

View File

@ -47,3 +47,21 @@ class Fighter(BaseComponent):
self.parent.render_order = RenderOrder.CORPSE self.parent.render_order = RenderOrder.CORPSE
self.engine.message_log.add_message(death_message, death_message_color) self.engine.message_log.add_message(death_message, death_message_color)
def heal(self, amount: int) -> int:
if self.hp == self.max_hp:
return 0
new_hp_value = self.hp + amount
if new_hp_value > self.max_hp:
new_hp_value = self.max_hp
amount_recovered = new_hp_value - self.hp
self.hp = new_hp_value
return amount_recovered
def take_damage(self, amount: int) -> None:
self.hp -= amount

View File

@ -6,6 +6,7 @@ from tcod.context import Context
from tcod.console import Console from tcod.console import Console
from tcod.map import compute_fov from tcod.map import compute_fov
import exceptions
from input_handlers import MainGameEventHandler from input_handlers import MainGameEventHandler
from message_log import MessageLog from message_log import MessageLog
from render_functions import render_bar, render_names_at_mouse_location from render_functions import render_bar, render_names_at_mouse_location
@ -27,7 +28,10 @@ class Engine:
def handle_enemy_turns(self) -> None: def handle_enemy_turns(self) -> None:
for entity in set(self.game_map.actors) - {self.player}: for entity in set(self.game_map.actors) - {self.player}:
if entity.ai: if entity.ai:
try:
entity.ai.perform() entity.ai.perform()
except exceptions.Impossible:
pass # Ignore impossible action exceptions from AI.
def update_fov(self) -> None: def update_fov(self) -> None:
"""Recompute the visible area based on the player's point of view.""" """Recompute the visible area based on the player's point of view."""

View File

@ -7,6 +7,7 @@ from render_order import RenderOrder
if TYPE_CHECKING: if TYPE_CHECKING:
from components.ai import BaseAI from components.ai import BaseAI
from components.consumable import Consumable
from components.fighter import Fighter from components.fighter import Fighter
from game_map import GameMap from game_map import GameMap
@ -105,3 +106,28 @@ class Actor(Entity):
def is_alive(self) -> bool: def is_alive(self) -> bool:
"""Returns True as long as this actor can perform actions.""" """Returns True as long as this actor can perform actions."""
return bool(self.ai) return bool(self.ai)
class Item(Entity):
def __init__(
self,
*,
x: int = 0,
y: int = 0,
char: str = "?",
color: Tuple[int, int, int] = (255, 255, 255),
name: str = "<Unamed>",
consumable: Consumable,
):
super().__init__(
x=x,
y=y,
char=char,
color=color,
name=name,
blocks_movement=False,
render_order=RenderOrder.ITEM,
)
self.consumable = consumable
self.consumable.parent = self

View File

@ -1,6 +1,7 @@
from components.ai import HostileEnemy from components.ai import HostileEnemy
from components.consumable import HealingConsumable
from components.fighter import Fighter from components.fighter import Fighter
from entity import Actor from entity import Actor, Item
player = Actor( player = Actor(
char="@", char="@",
@ -24,3 +25,10 @@ troll = Actor(
ai_cls=HostileEnemy, ai_cls=HostileEnemy,
fighter=Fighter(hp=16, defense=1, power=4) fighter=Fighter(hp=16, defense=1, power=4)
) )
health_potion = Item(
char="!",
color=(127, 0, 255),
name="Health Potion",
consumable=HealingConsumable(amount=4)
)

5
exceptions.py Normal file
View File

@ -0,0 +1,5 @@
class Impossible(Exception):
"""Exception raised when an action is impossible to be performed.
The reason is given as the exception message.
"""

View File

@ -4,7 +4,14 @@ from typing import overload, Optional, TYPE_CHECKING
import tcod.event import tcod.event
from actions import Action, BumpAction, EscapeAction, WaitAction from actions import (
Action,
BumpAction,
EscapeAction,
WaitAction
)
import color
import exceptions
if TYPE_CHECKING: if TYPE_CHECKING:
from engine import Engine from engine import Engine
@ -52,10 +59,28 @@ class EventHandler(tcod.event.EventDispatch[Action]):
def __init__(self, engine: Engine): def __init__(self, engine: Engine):
self.engine = engine self.engine = engine
def handle_events(self, context: tcod.context.Context) -> None: def handle_events(self, event: tcod.event.Event) -> None:
for event in tcod.event.wait(): self.handle_action(self.dispatch(event))
context.convert_event(event)
self.dispatch(event) def handle_action(self, action: Optional[Action]) -> bool:
"""Handle actions returned from event methods.
Returns True if the action will advance a turn.
"""
if action is None:
return False
try:
action.perform()
except exceptions.Impossible as exc:
self.engine.message_log.add_message(exc.args[0], color.impossible)
return False # Skip enemy turn on exceptions.
self.engine.handle_enemy_turns()
self.engine.update_fov()
return True
def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
if self.engine.game_map.in_bounds(event.tile.x, event.tile.y): if self.engine.game_map.in_bounds(event.tile.x, event.tile.y):
@ -69,20 +94,6 @@ class EventHandler(tcod.event.EventDispatch[Action]):
class MainGameEventHandler(EventHandler): class MainGameEventHandler(EventHandler):
def handle_events(self, context: tcod.context.Context) -> None:
for event in tcod.event.wait():
context.convert_event(event)
action = self.dispatch(event)
if action is None:
continue
action.perform()
self.engine.handle_enemy_turns()
self.engine.update_fov()
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
action: Optional[Action] = None action: Optional[Action] = None
@ -106,25 +117,9 @@ class MainGameEventHandler(EventHandler):
class GameOverEventHandler(EventHandler): class GameOverEventHandler(EventHandler):
def handle_events(self, context: tcod.context.Context) -> None: def ev_keydown(self, event: tcod.event.KeyDown) -> None:
for event in tcod.event.wait(): if event.sym == tcod.event.K_ESCAPE:
action = self.dispatch(event) raise SystemExit()
if action is None:
continue
action.perform()
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
action: Optional[Action] = None
key = event.sym
if key == tcod.event.K_ESCAPE:
action = EscapeAction(self.engine.player)
# No valid key was pressed
return action
CURSOR_Y_KEYS = { CURSOR_Y_KEYS = {

12
main.py
View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import copy import copy
import traceback
import tcod import tcod
@ -21,6 +22,7 @@ def main() -> None:
max_rooms = 30 max_rooms = 30
max_monsters_per_room = 2 max_monsters_per_room = 2
max_items_per_room = 2
tileset = tcod.tileset.load_tilesheet( tileset = tcod.tileset.load_tilesheet(
"dejavu10x10_gs_tc.png", "dejavu10x10_gs_tc.png",
@ -40,6 +42,7 @@ def main() -> None:
map_width, map_width,
map_height, map_height,
max_monsters_per_room, max_monsters_per_room,
max_items_per_room,
engine, engine,
) )
engine.update_fov() engine.update_fov()
@ -62,7 +65,14 @@ def main() -> None:
engine.event_handler.on_render(console=root_console) engine.event_handler.on_render(console=root_console)
context.present(root_console) context.present(root_console)
engine.event_handler.handle_events(context) try:
for event in tcod.event.wait():
context.convert_event(event)
engine.event_handler.handle_events(event)
except Exception: # Handle exceptions in game.
traceback.print_exc() # Print error to stderr.
# Then print the error to the message log.
engine.message_log.add_message(traceback.format_exc(), color.error)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -46,8 +46,10 @@ def place_entities(
room: RectangularRoom, room: RectangularRoom,
dungeon: GameMap, dungeon: GameMap,
maximum_monsters: int, maximum_monsters: int,
maximum_items: int,
) -> None: ) -> None:
number_of_monsters = random.randint(0, maximum_monsters) number_of_monsters = random.randint(0, maximum_monsters)
number_of_items = random.randint(0, maximum_items)
for i in range(number_of_monsters): for i in range(number_of_monsters):
x = random.randint(room.x1 + 1, room.x2 - 1) x = random.randint(room.x1 + 1, room.x2 - 1)
@ -59,6 +61,13 @@ def place_entities(
else: else:
entity_factories.troll.spawn(dungeon, x, y) entity_factories.troll.spawn(dungeon, x, y)
for i in range(number_of_items):
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
entity_factories.health_potion.spawn(dungeon, x, y)
def tunnel_between(start: Tuple[int, int], end: Tuple[int, int]) -> Iterator[Tuple[int, int]]: def tunnel_between(start: Tuple[int, int], end: Tuple[int, int]) -> Iterator[Tuple[int, int]]:
"""Return an L-shaped tunnel between these two points.""" """Return an L-shaped tunnel between these two points."""
@ -86,6 +95,7 @@ def generate_dungeon(
map_width: int, map_width: int,
map_height: int, map_height: int,
max_monsters_per_room: int, max_monsters_per_room: int,
max_items_per_room: int,
engine: Engine, engine: Engine,
) -> GameMap: ) -> GameMap:
"""Generate a new dungeon map.""" """Generate a new dungeon map."""
@ -120,7 +130,7 @@ def generate_dungeon(
for x, y in tunnel_between(rooms[-1].center, new_room.center): for x, y in tunnel_between(rooms[-1].center, new_room.center):
dungeon.tiles[x, y] = tile_types.floor dungeon.tiles[x, y] = tile_types.floor
place_entities(new_room, dungeon, max_monsters_per_room) place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room)
# Finally, append the new room to the list. # Finally, append the new room to the list.
rooms.append(new_room) rooms.append(new_room)