Skip to content

Commit 2ad2c39

Browse files
committed
Part 9: Ranged Scrolls and Targeting
1 parent 3aa1220 commit 2ad2c39

File tree

11 files changed

+245
-130
lines changed

11 files changed

+245
-130
lines changed

game/actions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def __init__(self, entity: game.entity.Actor):
126126
def perform(self) -> None:
127127
# Type check to ensure entity is an Actor with inventory
128128
assert isinstance(self.entity, game.entity.Actor), "Entity must be an Actor for inventory access"
129-
129+
130130
actor_location_x = self.entity.x
131131
actor_location_y = self.entity.y
132132
inventory = self.entity.inventory

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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

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

56
import numpy as np
67
import tcod
@@ -69,7 +70,48 @@ def perform(self) -> None:
6970
if self.path:
7071
dest_x, dest_y = self.path.pop(0)
7172
return game.actions.MovementAction(
72-
self.entity,
73-
dest_x - self.entity.x,
74-
dest_y - self.entity.y,
73+
self.entity, dest_x - self.entity.x, dest_y - self.entity.y,
7574
).perform()
75+
76+
77+
class ConfusedEnemy(BaseAI):
78+
"""
79+
A confused enemy will stumble around aimlessly for a given number of turns, then revert back to its previous AI.
80+
If an actor occupies a tile it is randomly moving into, it will attack.
81+
"""
82+
83+
def __init__(
84+
self, entity: game.entity.Actor, previous_ai: Optional[BaseAI], turns_remaining: int
85+
):
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(
95+
f"The {self.entity.name} is no longer confused."
96+
)
97+
self.entity.ai = self.previous_ai
98+
else:
99+
# Pick a random direction
100+
direction_x, direction_y = random.choice(
101+
[
102+
(-1, -1), # Northwest
103+
(0, -1), # North
104+
(1, -1), # Northeast
105+
(-1, 0), # West
106+
(1, 0), # East
107+
(-1, 1), # Southwest
108+
(0, 1), # South
109+
(1, 1), # Southeast
110+
]
111+
)
112+
113+
self.turns_remaining -= 1
114+
115+
# The actor will either try to move or attack in the chosen random direction.
116+
# It's possible the actor will just bump into the wall, wasting a turn.
117+
return game.actions.BumpAction(self.entity, direction_x, direction_y,).perform()

game/components/consumable.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
import game.actions
66
import game.color
7+
import game.components.ai
78
import game.components.base_component
89
import game.exceptions
10+
import game.input_handlers
911

1012
if TYPE_CHECKING:
1113
import game.entity
@@ -51,3 +53,100 @@ def activate(self, action: game.actions.ItemAction) -> None:
5153
self.consume()
5254
else:
5355
raise game.exceptions.Impossible("Your health is already full.")
56+
57+
58+
class LightningDamageConsumable(Consumable):
59+
def __init__(self, damage: int, maximum_range: int):
60+
self.damage = damage
61+
self.maximum_range = maximum_range
62+
63+
def activate(self, action: game.actions.ItemAction) -> None:
64+
consumer = action.entity
65+
target = None
66+
closest_distance = self.maximum_range + 1.0
67+
68+
for actor in self.engine.game_map.actors:
69+
if actor is not consumer and self.parent.gamemap.visible[actor.x, actor.y]:
70+
distance = consumer.distance(actor.x, actor.y)
71+
72+
if distance < closest_distance:
73+
target = actor
74+
closest_distance = distance
75+
76+
if target:
77+
self.engine.message_log.add_message(
78+
f"A lighting bolt strikes the {target.name} with a loud thunder, for {self.damage} damage!"
79+
)
80+
target.fighter.take_damage(self.damage)
81+
self.consume()
82+
else:
83+
raise game.exceptions.Impossible("No enemy is close enough to strike.")
84+
85+
86+
class ConfusionConsumable(Consumable):
87+
def __init__(self, number_of_turns: int):
88+
self.number_of_turns = number_of_turns
89+
90+
def get_action(self, consumer: game.entity.Actor) -> Optional[game.input_handlers.ActionOrHandler]:
91+
self.engine.message_log.add_message(
92+
"Select a target location.", game.color.needs_target
93+
)
94+
return game.input_handlers.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 = game.components.ai.ConfusedEnemy(
115+
entity=target, previous_ai=target.ai, turns_remaining=self.number_of_turns,
116+
)
117+
self.consume()
118+
119+
120+
class FireballDamageConsumable(Consumable):
121+
def __init__(self, damage: int, radius: int):
122+
self.damage = damage
123+
self.radius = radius
124+
125+
def get_action(self, consumer: game.entity.Actor) -> Optional[game.input_handlers.ActionOrHandler]:
126+
self.engine.message_log.add_message(
127+
"Select a target location.", game.color.needs_target
128+
)
129+
return game.input_handlers.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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import game.components.consumable
1010
import game.components.fighter
1111
import game.components.inventory
12-
12+
1313
import game.game_map
1414

1515

@@ -40,7 +40,7 @@ def __init__(
4040
if parent:
4141
# If parent isn't provided now then it will be set later.
4242
self.parent = parent
43-
if hasattr(parent, 'entities'):
43+
if hasattr(parent, "entities"):
4444
parent.entities.add(self)
4545

4646
@property
@@ -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: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

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

55
from tcod import libtcodpy
66
import tcod.event
@@ -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=game.color.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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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(game.entity_factories.health_potion)
86+
item_chance = random.random()
87+
88+
if item_chance < 0.7:
89+
item = copy.deepcopy(game.entity_factories.health_potion)
90+
elif item_chance < 0.8:
91+
item = copy.deepcopy(game.entity_factories.fireball_scroll)
92+
elif item_chance < 0.9:
93+
item = copy.deepcopy(game.entity_factories.confusion_scroll)
94+
else:
95+
item = copy.deepcopy(game.entity_factories.lightning_scroll)
8796

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

lint_and_continue.sh

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)