Skip to content

Commit 45dbe1d

Browse files
committed
Part 9: Ranged Scrolls and Targeting
1 parent ebc7366 commit 45dbe1d

File tree

8 files changed

+253
-18
lines changed

8 files changed

+253
-18
lines changed

game/color.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
welcome_text = (0x20, 0xA0, 0xFF)
1313
health_recovered = (0x0, 0xFF, 0x0)
1414

15+
needs_target = (0x3F, 0xFF, 0xFF)
16+
status_effect_applied = (0x3F, 0xFF, 0x3F)
17+
1518
bar_text = white
1619
bar_filled = (0x0, 0x60, 0x0)
1720
bar_empty = (0x40, 0x10, 0x10)
21+
22+
red = (0xFF, 0x00, 0x00)

game/components/ai.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, List, Tuple
3+
from typing import TYPE_CHECKING, List, Optional, Tuple
4+
import random
45

56
import numpy as np
67
import tcod
78

8-
from game.actions import Action, MeleeAction, MovementAction
9+
from game.actions import Action, BumpAction, MeleeAction, MovementAction
910

1011
if TYPE_CHECKING:
1112
import game.entity
@@ -73,3 +74,46 @@ def perform(self) -> None:
7374
dest_x - self.entity.x,
7475
dest_y - self.entity.y,
7576
).perform()
77+
78+
79+
class ConfusedEnemy(BaseAI):
80+
"""
81+
A confused enemy will stumble around aimlessly for a given number of turns, then revert back to its previous AI.
82+
If an actor occupies a tile it is randomly moving into, it will attack.
83+
"""
84+
85+
def __init__(self, entity: game.entity.Actor, previous_ai: Optional[BaseAI], turns_remaining: int):
86+
super().__init__(entity)
87+
88+
self.previous_ai = previous_ai
89+
self.turns_remaining = turns_remaining
90+
91+
def perform(self) -> None:
92+
# Revert the AI back to the original state if the effect has run its course.
93+
if self.turns_remaining <= 0:
94+
self.engine.message_log.add_message(f"The {self.entity.name} is no longer confused.")
95+
self.entity.ai = self.previous_ai
96+
else:
97+
# Pick a random direction
98+
direction_x, direction_y = random.choice(
99+
[
100+
(-1, -1), # Northwest
101+
(0, -1), # North
102+
(1, -1), # Northeast
103+
(-1, 0), # West
104+
(1, 0), # East
105+
(-1, 1), # Southwest
106+
(0, 1), # South
107+
(1, 1), # Southeast
108+
]
109+
)
110+
111+
self.turns_remaining -= 1
112+
113+
# The actor will either try to move or attack in the chosen random direction.
114+
# It's possible the actor will just bump into the wall, wasting a turn.
115+
return BumpAction(
116+
self.entity,
117+
direction_x,
118+
direction_y,
119+
).perform()

game/components/consumable.py

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@
44

55
from game.actions import Action, ItemAction
66
from game.color import health_recovered
7+
from game.components.ai import ConfusedEnemy
78
from game.components.base_component import BaseComponent
9+
from game.components.inventory import Inventory
10+
from game.entity import Actor, Item
811
from game.exceptions import Impossible
9-
import game.entity
12+
from game.input_handlers import ActionOrHandler, AreaRangedAttackHandler, SingleRangedAttackHandler
13+
14+
if TYPE_CHECKING:
15+
import game.actions
1016

1117

1218
class Consumable(BaseComponent):
13-
parent: game.entity.Item
19+
parent: Item
1420

15-
def get_action(self, consumer: game.entity.Actor) -> Optional[Action]:
21+
def get_action(self, consumer: Actor) -> Optional[Action]:
1622
"""Try to return the action for this item."""
1723
return ItemAction(consumer, self.parent)
1824

@@ -27,7 +33,7 @@ def consume(self) -> None:
2733
"""Remove the consumed item from its containing inventory."""
2834
entity = self.parent
2935
inventory = entity.parent
30-
if isinstance(inventory, game.components.inventory.Inventory):
36+
if isinstance(inventory, Inventory):
3137
inventory.items.remove(entity)
3238

3339

@@ -37,7 +43,7 @@ def __init__(self, amount: int):
3743

3844
def activate(self, action: ItemAction) -> None:
3945
# Type check to ensure consumer is an Actor
40-
assert isinstance(action.entity, game.entity.Actor), "Consumer must be an Actor"
46+
assert isinstance(action.entity, Actor), "Consumer must be an Actor"
4147
consumer = action.entity
4248
amount_recovered = consumer.fighter.heal(self.amount)
4349

@@ -49,3 +55,98 @@ def activate(self, action: ItemAction) -> None:
4955
self.consume()
5056
else:
5157
raise Impossible("Your health is already full.")
58+
59+
60+
class LightningDamageConsumable(Consumable):
61+
def __init__(self, damage: int, maximum_range: int):
62+
self.damage = damage
63+
self.maximum_range = maximum_range
64+
65+
def activate(self, action: game.actions.ItemAction) -> None:
66+
consumer = action.entity
67+
target = None
68+
closest_distance = self.maximum_range + 1.0
69+
70+
for actor in self.engine.game_map.actors:
71+
if actor is not consumer and self.parent.gamemap.visible[actor.x, actor.y]:
72+
distance = consumer.distance(actor.x, actor.y)
73+
74+
if distance < closest_distance:
75+
target = actor
76+
closest_distance = distance
77+
78+
if target:
79+
self.engine.message_log.add_message(
80+
f"A lighting bolt strikes the {target.name} with a loud thunder, for {self.damage} damage!"
81+
)
82+
target.fighter.take_damage(self.damage)
83+
self.consume()
84+
else:
85+
raise game.exceptions.Impossible("No enemy is close enough to strike.")
86+
87+
88+
class ConfusionConsumable(Consumable):
89+
def __init__(self, number_of_turns: int):
90+
self.number_of_turns = number_of_turns
91+
92+
def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]:
93+
self.engine.message_log.add_message("Select a target location.", game.color.needs_target)
94+
return SingleRangedAttackHandler(
95+
self.engine,
96+
callback=lambda xy: game.actions.ItemAction(consumer, self.parent, xy),
97+
)
98+
99+
def activate(self, action: game.actions.ItemAction) -> None:
100+
consumer = action.entity
101+
target = action.target_actor
102+
103+
if not self.engine.game_map.visible[action.target_xy]:
104+
raise game.exceptions.Impossible("You cannot target an area that you cannot see.")
105+
if not target:
106+
raise game.exceptions.Impossible("You must select an enemy to target.")
107+
if target is consumer:
108+
raise game.exceptions.Impossible("You cannot confuse yourself!")
109+
110+
self.engine.message_log.add_message(
111+
f"The eyes of the {target.name} look vacant, as it starts to stumble around!",
112+
game.color.status_effect_applied,
113+
)
114+
target.ai = ConfusedEnemy(
115+
entity=target,
116+
previous_ai=target.ai,
117+
turns_remaining=self.number_of_turns,
118+
)
119+
self.consume()
120+
121+
122+
class FireballDamageConsumable(Consumable):
123+
def __init__(self, damage: int, radius: int):
124+
self.damage = damage
125+
self.radius = radius
126+
127+
def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]:
128+
self.engine.message_log.add_message("Select a target location.", game.color.needs_target)
129+
return AreaRangedAttackHandler(
130+
self.engine,
131+
radius=self.radius,
132+
callback=lambda xy: game.actions.ItemAction(consumer, self.parent, xy),
133+
)
134+
135+
def activate(self, action: game.actions.ItemAction) -> None:
136+
target_xy = action.target_xy
137+
138+
if not self.engine.game_map.visible[target_xy]:
139+
raise game.exceptions.Impossible("You cannot target an area that you cannot see.")
140+
141+
targets_hit = False
142+
for actor in self.engine.game_map.actors:
143+
if actor.distance(*target_xy) <= self.radius:
144+
self.engine.message_log.add_message(
145+
f"The {actor.name} is engulfed in a fiery explosion, taking {self.damage} damage!"
146+
)
147+
actor.fighter.take_damage(self.damage)
148+
targets_hit = True
149+
150+
if not targets_hit:
151+
raise game.exceptions.Impossible("There are no targets in the radius.")
152+
self.consume()

game/entity.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ def move(self, dx: int, dy: int) -> None:
6666
self.x += dx
6767
self.y += dy
6868

69+
def distance(self, x: int, y: int) -> float:
70+
"""
71+
Return the distance between the current entity and the given (x, y) coordinate.
72+
"""
73+
return float(((x - self.x) ** 2 + (y - self.y) ** 2) ** 0.5)
74+
6975

7076
class Actor(Entity):
7177
def __init__(

game/entity_factories.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from game.components.ai import HostileEnemy
2-
from game.components.consumable import HealingConsumable
2+
from game.components.consumable import (
3+
ConfusionConsumable,
4+
FireballDamageConsumable,
5+
HealingConsumable,
6+
LightningDamageConsumable,
7+
)
38
from game.components.fighter import Fighter
49
from game.components.inventory import Inventory
510
from game.entity import Actor, Item
@@ -37,3 +42,24 @@
3742
name="Health Potion",
3843
consumable=HealingConsumable(amount=4),
3944
)
45+
46+
lightning_scroll = Item(
47+
char="~",
48+
color=(255, 255, 0),
49+
name="Lightning Scroll",
50+
consumable=LightningDamageConsumable(damage=20, maximum_range=5),
51+
)
52+
53+
confusion_scroll = Item(
54+
char="~",
55+
color=(207, 63, 255),
56+
name="Confusion Scroll",
57+
consumable=ConfusionConsumable(number_of_turns=10),
58+
)
59+
60+
fireball_scroll = Item(
61+
char="~",
62+
color=(255, 0, 0),
63+
name="Fireball Scroll",
64+
consumable=FireballDamageConsumable(damage=12, radius=3),
65+
)

game/input_handlers.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from __future__ import annotations
22

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

55
from tcod import libtcodpy
66
import tcod.event
77

88
from game.actions import Action, BumpAction, DropItem, EscapeAction, PickupAction, WaitAction
9-
from game.color import black, impossible, invalid, white
9+
from game.color import black, impossible, invalid, red, white
1010
from game.entity import Actor
1111
from game.exceptions import Impossible
1212

@@ -365,7 +365,7 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
365365

366366
def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]:
367367
"""Left click confirms a selection."""
368-
x, y = int(event.tile.x), int(event.tile.y)
368+
x, y = int(event.position.x), int(event.position.y)
369369
if self.engine.game_map.in_bounds(x, y):
370370
if event.button == 1:
371371
return self.on_index_selected(x, y)
@@ -382,3 +382,51 @@ class LookHandler(SelectIndexHandler):
382382
def on_index_selected(self, x: int, y: int) -> MainGameEventHandler:
383383
"""Return to main handler."""
384384
return MainGameEventHandler(self.engine)
385+
386+
387+
class SingleRangedAttackHandler(SelectIndexHandler):
388+
"""Handles targeting a single enemy. Only the enemy selected will be affected."""
389+
390+
def __init__(
391+
self, engine: game.engine.Engine, callback: Callable[[Tuple[int, int]], Optional[game.actions.Action]]
392+
):
393+
super().__init__(engine)
394+
395+
self.callback = callback
396+
397+
def on_index_selected(self, x: int, y: int) -> Optional[game.actions.Action]:
398+
return self.callback((x, y))
399+
400+
401+
class AreaRangedAttackHandler(SelectIndexHandler):
402+
"""Handles targeting an area within a given radius. Any entity within the area will be affected."""
403+
404+
def __init__(
405+
self,
406+
engine: game.engine.Engine,
407+
radius: int,
408+
callback: Callable[[Tuple[int, int]], Optional[game.actions.Action]],
409+
):
410+
super().__init__(engine)
411+
412+
self.radius = radius
413+
self.callback = callback
414+
415+
def on_render(self, console: tcod.console.Console) -> None:
416+
"""Highlight the tile under the cursor."""
417+
super().on_render(console)
418+
419+
x, y = self.engine.mouse_location
420+
421+
# Draw a rectangle around the targeted area, so the player can see the affected tiles.
422+
console.draw_frame(
423+
x=x - self.radius - 1,
424+
y=y - self.radius - 1,
425+
width=self.radius**2,
426+
height=self.radius**2,
427+
fg=red,
428+
clear=False,
429+
)
430+
431+
def on_index_selected(self, x: int, y: int) -> Optional[game.actions.Action]:
432+
return self.callback((x, y))

game/procgen.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import tcod
88

99
from game.entity import Entity
10-
from game.entity_factories import health_potion, orc, troll
10+
from game.entity_factories import confusion_scroll, fireball_scroll, health_potion, lightning_scroll, orc, troll
1111
from game.game_map import GameMap
1212
from game.tiles import floor
1313

@@ -83,7 +83,16 @@ def place_entities(
8383
y = random.randint(room.y1 + 1, room.y2 - 1)
8484

8585
if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
86-
item = copy.deepcopy(health_potion)
86+
item_chance = random.random()
87+
88+
if item_chance < 0.7:
89+
item = copy.deepcopy(health_potion)
90+
elif item_chance < 0.8:
91+
item = copy.deepcopy(fireball_scroll)
92+
elif item_chance < 0.9:
93+
item = copy.deepcopy(confusion_scroll)
94+
else:
95+
item = copy.deepcopy(lightning_scroll)
8796

8897
item.place(x, y, dungeon)
8998

main.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ def main() -> None:
4141
)
4242
engine.update_fov()
4343

44-
# Part 10 refactoring: Track handler in main loop
4544
handler: BaseEventHandler = MainGameEventHandler(engine)
4645

4746
with tcod.context.new(
@@ -57,11 +56,8 @@ def main() -> None:
5756
handler.on_render(console=root_console)
5857
context.present(root_console)
5958

60-
# Part 10 refactoring: Handler manages its own state transitions
6159
for event in tcod.event.wait():
62-
# libtcodpy deprecation: convert mouse events
63-
if isinstance(event, tcod.event.MouseMotion):
64-
event = context.convert_event(event)
60+
event = context.convert_event(event)
6561
handler = handler.handle_events(event)
6662

6763

0 commit comments

Comments
 (0)