Part 9: Ranged Combat and Targeting
In Part 8, we added items and inventory. Now we will implement ranged combat with a targeting system. Instead of bumping into enemies, players can attack from a distance - but they will need to select their target carefully.
What You Will Learn
- Implementing a targeting mode
- Creating a visual targeting cursor
- Moving the cursor with keyboard controls
- Validating line-of-sight for targets
- Executing ranged attacks
- Managing game state with multiple modes
Targeting Design Philosophy
Ranged combat adds tactical depth:
| Aspect | Melee | Ranged |
|---|---|---|
| Trigger | Move into enemy | Select from distance |
| Risk | Always adjacent | Can stay at range |
| Complexity | Simple bump | Requires targeting |
| Balance | Immediate | May require ammo/cooldown |
We will implement a simple ranged attack that requires line of sight but no ammunition.
The Complete Code
Create a file called part_09_ranged.py:
"""McRogueFace Tutorial - Part 9: Ranged Combat and Targeting
Add ranged attacks with a targeting system.
"""
import mcrfpy
import random
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
# =============================================================================
# Constants
# =============================================================================
# Sprite indices for CP437 tileset
SPRITE_WALL = 35 # '#' - wall
SPRITE_FLOOR = 46 # '.' - floor
SPRITE_PLAYER = 64 # '@' - player
SPRITE_CORPSE = 37 # '%' - remains
SPRITE_POTION = 173 # Potion sprite
SPRITE_CURSOR = 88 # 'X' - targeting cursor
# Enemy sprites
SPRITE_GOBLIN = 103 # 'g'
SPRITE_ORC = 111 # 'o'
SPRITE_TROLL = 116 # 't'
# Grid dimensions
GRID_WIDTH = 50
GRID_HEIGHT = 30
# Room generation parameters
ROOM_MIN_SIZE = 6
ROOM_MAX_SIZE = 12
MAX_ROOMS = 8
# Enemy spawn parameters
MAX_ENEMIES_PER_ROOM = 3
# Item spawn parameters
MAX_ITEMS_PER_ROOM = 2
# FOV and targeting settings
FOV_RADIUS = 8
RANGED_ATTACK_RANGE = 6
RANGED_ATTACK_DAMAGE = 4
# Visibility colors
COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0)
COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180)
COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255)
# Message colors
COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200)
COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150)
COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50)
COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100)
COLOR_HEAL = mcrfpy.Color(100, 255, 100)
COLOR_PICKUP = mcrfpy.Color(100, 200, 255)
COLOR_INFO = mcrfpy.Color(100, 100, 255)
COLOR_WARNING = mcrfpy.Color(255, 200, 50)
COLOR_INVALID = mcrfpy.Color(255, 100, 100)
COLOR_RANGED = mcrfpy.Color(255, 255, 100)
# UI Layout constants
UI_TOP_HEIGHT = 60
UI_BOTTOM_HEIGHT = 150
GAME_AREA_Y = UI_TOP_HEIGHT
GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT
# =============================================================================
# Game Modes
# =============================================================================
class GameMode(Enum):
"""The current game input mode."""
NORMAL = "normal"
TARGETING = "targeting"
# =============================================================================
# Fighter Component
# =============================================================================
@dataclass
class Fighter:
"""Combat stats for an entity."""
hp: int
max_hp: int
attack: int
defense: int
name: str
is_player: bool = False
@property
def is_alive(self) -> bool:
return self.hp > 0
def take_damage(self, amount: int) -> int:
actual_damage = min(self.hp, amount)
self.hp -= actual_damage
return actual_damage
def heal(self, amount: int) -> int:
actual_heal = min(self.max_hp - self.hp, amount)
self.hp += actual_heal
return actual_heal
# =============================================================================
# Item Component
# =============================================================================
@dataclass
class Item:
"""Data for an item that can be picked up and used."""
name: str
item_type: str
heal_amount: int = 0
def describe(self) -> str:
if self.item_type == "health_potion":
return f"Restores {self.heal_amount} HP"
return "Unknown item"
# =============================================================================
# Inventory System
# =============================================================================
@dataclass
class Inventory:
"""Container for items the player is carrying."""
capacity: int = 10
items: list = field(default_factory=list)
def add(self, item: Item) -> bool:
if len(self.items) >= self.capacity:
return False
self.items.append(item)
return True
def remove(self, index: int) -> Optional[Item]:
if 0 <= index < len(self.items):
return self.items.pop(index)
return None
def get(self, index: int) -> Optional[Item]:
if 0 <= index < len(self.items):
return self.items[index]
return None
def is_full(self) -> bool:
return len(self.items) >= self.capacity
def count(self) -> int:
return len(self.items)
# =============================================================================
# Templates
# =============================================================================
ITEM_TEMPLATES = {
"health_potion": {
"name": "Health Potion",
"sprite": SPRITE_POTION,
"item_type": "health_potion",
"heal_amount": 10
}
}
ENEMY_TEMPLATES = {
"goblin": {
"sprite": SPRITE_GOBLIN,
"hp": 6,
"attack": 3,
"defense": 0
},
"orc": {
"sprite": SPRITE_ORC,
"hp": 10,
"attack": 4,
"defense": 1
},
"troll": {
"sprite": SPRITE_TROLL,
"hp": 16,
"attack": 6,
"defense": 2
}
}
# =============================================================================
# Message Log System
# =============================================================================
class MessageLog:
"""A message log that displays recent game messages with colors."""
def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6):
self.x = x
self.y = y
self.width = width
self.height = height
self.max_messages = max_messages
self.messages: list[tuple[str, mcrfpy.Color]] = []
self.captions: list[mcrfpy.Caption] = []
self.frame = mcrfpy.Frame(
pos=(x, y),
size=(width, height)
)
self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200)
self.frame.outline = 2
self.frame.outline_color = mcrfpy.Color(80, 80, 100)
line_height = 20
for i in range(max_messages):
caption = mcrfpy.Caption(
pos=(x + 10, y + 5 + i * line_height),
text=""
)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(200, 200, 200)
self.captions.append(caption)
def add_to_scene(self, scene: mcrfpy.Scene) -> None:
scene.children.append(self.frame)
for caption in self.captions:
scene.children.append(caption)
def add(self, text: str, color: mcrfpy.Color = None) -> None:
if color is None:
color = mcrfpy.Color(200, 200, 200)
self.messages.append((text, color))
while len(self.messages) > self.max_messages:
self.messages.pop(0)
self._refresh()
def _refresh(self) -> None:
for i, caption in enumerate(self.captions):
if i < len(self.messages):
text, color = self.messages[i]
caption.text = text
caption.fill_color = color
else:
caption.text = ""
def clear(self) -> None:
self.messages.clear()
self._refresh()
# =============================================================================
# Health Bar System
# =============================================================================
class HealthBar:
"""A visual health bar using nested frames."""
def __init__(self, x: int, y: int, width: int, height: int):
self.x = x
self.y = y
self.width = width
self.height = height
self.max_hp = 30
self.current_hp = 30
self.bg_frame = mcrfpy.Frame(
pos=(x, y),
size=(width, height)
)
self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0)
self.bg_frame.outline = 2
self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150)
self.fg_frame = mcrfpy.Frame(
pos=(x + 2, y + 2),
size=(width - 4, height - 4)
)
self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0)
self.fg_frame.outline = 0
self.label = mcrfpy.Caption(
pos=(x + 5, y + 2),
text=f"HP: {self.current_hp}/{self.max_hp}"
)
self.label.font_size = 16
self.label.fill_color = mcrfpy.Color(255, 255, 255)
def add_to_scene(self, scene: mcrfpy.Scene) -> None:
scene.children.append(self.bg_frame)
scene.children.append(self.fg_frame)
scene.children.append(self.label)
def update(self, current_hp: int, max_hp: int) -> None:
self.current_hp = current_hp
self.max_hp = max_hp
percent = max(0, current_hp / max_hp) if max_hp > 0 else 0
inner_width = self.width - 4
self.fg_frame.resize(int(inner_width * percent), self.height - 4)
self.label.text = f"HP: {current_hp}/{max_hp}"
if percent > 0.6:
self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0)
elif percent > 0.3:
self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0)
else:
self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0)
# =============================================================================
# Inventory Panel
# =============================================================================
class InventoryPanel:
"""A panel displaying the player's inventory."""
def __init__(self, x: int, y: int, width: int, height: int):
self.x = x
self.y = y
self.width = width
self.height = height
self.captions: list[mcrfpy.Caption] = []
self.frame = mcrfpy.Frame(
pos=(x, y),
size=(width, height)
)
self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200)
self.frame.outline = 2
self.frame.outline_color = mcrfpy.Color(80, 80, 100)
self.title = mcrfpy.Caption(
pos=(x + 10, y + 5),
text="Inventory (G:pickup, 1-5:use)"
)
self.title.font_size = 14
self.title.fill_color = mcrfpy.Color(200, 200, 255)
for i in range(5):
caption = mcrfpy.Caption(
pos=(x + 10, y + 25 + i * 18),
text=""
)
caption.font_size = 13
caption.fill_color = mcrfpy.Color(180, 180, 180)
self.captions.append(caption)
def add_to_scene(self, scene: mcrfpy.Scene) -> None:
scene.children.append(self.frame)
scene.children.append(self.title)
for caption in self.captions:
scene.children.append(caption)
def update(self, inventory: Inventory) -> None:
for i, caption in enumerate(self.captions):
if i < len(inventory.items):
item = inventory.items[i]
caption.text = f"{i+1}. {item.name}"
caption.fill_color = mcrfpy.Color(180, 180, 180)
else:
caption.text = f"{i+1}. ---"
caption.fill_color = mcrfpy.Color(80, 80, 80)
# =============================================================================
# Mode Display
# =============================================================================
class ModeDisplay:
"""Displays the current game mode."""
def __init__(self, x: int, y: int):
self.caption = mcrfpy.Caption(
pos=(x, y),
text="[NORMAL MODE]"
)
self.caption.font_size = 16
self.caption.fill_color = mcrfpy.Color(100, 255, 100)
def add_to_scene(self, scene: mcrfpy.Scene) -> None:
scene.children.append(self.caption)
def update(self, mode: GameMode) -> None:
if mode == GameMode.NORMAL:
self.caption.text = "[NORMAL MODE] - F: Ranged attack"
self.caption.fill_color = mcrfpy.Color(100, 255, 100)
elif mode == GameMode.TARGETING:
self.caption.text = "[TARGETING] - Arrows: Move, Enter: Fire, Esc: Cancel"
self.caption.fill_color = mcrfpy.Color(255, 255, 100)
# =============================================================================
# Global State
# =============================================================================
entity_data: dict[mcrfpy.Entity, Fighter] = {}
item_data: dict[mcrfpy.Entity, Item] = {}
player: Optional[mcrfpy.Entity] = None
player_inventory: Optional[Inventory] = None
grid: Optional[mcrfpy.Grid] = None
fov_layer = None
texture: Optional[mcrfpy.Texture] = None
game_over: bool = False
dungeon_level: int = 1
# Game mode state
game_mode: GameMode = GameMode.NORMAL
target_cursor: Optional[mcrfpy.Entity] = None
target_x: int = 0
target_y: int = 0
# UI components
message_log: Optional[MessageLog] = None
health_bar: Optional[HealthBar] = None
inventory_panel: Optional[InventoryPanel] = None
mode_display: Optional[ModeDisplay] = None
# =============================================================================
# Room Class
# =============================================================================
class RectangularRoom:
"""A rectangular room with its position and size."""
def __init__(self, x: int, y: int, width: int, height: int):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self) -> tuple[int, int]:
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self) -> tuple[slice, slice]:
return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2)
def intersects(self, other: "RectangularRoom") -> bool:
return (
self.x1 <= other.x2 and
self.x2 >= other.x1 and
self.y1 <= other.y2 and
self.y2 >= other.y1
)
# =============================================================================
# Exploration Tracking
# =============================================================================
explored: list[list[bool]] = []
def init_explored() -> None:
global explored
explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
def mark_explored(x: int, y: int) -> None:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
explored[y][x] = True
def is_explored(x: int, y: int) -> bool:
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
return explored[y][x]
return False
# =============================================================================
# Dungeon Generation
# =============================================================================
def fill_with_walls(target_grid: mcrfpy.Grid) -> None:
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = target_grid.at(x, y)
cell.tilesprite = SPRITE_WALL
cell.walkable = False
cell.transparent = False
def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None:
inner_x, inner_y = room.inner
for y in range(inner_y.start, inner_y.stop):
for x in range(inner_x.start, inner_x.stop):
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
cell = target_grid.at(x, y)
cell.tilesprite = SPRITE_FLOOR
cell.walkable = True
cell.transparent = True
def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None:
for x in range(min(x1, x2), max(x1, x2) + 1):
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
cell = target_grid.at(x, y)
cell.tilesprite = SPRITE_FLOOR
cell.walkable = True
cell.transparent = True
def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None:
for y in range(min(y1, y2), max(y1, y2) + 1):
if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
cell = target_grid.at(x, y)
cell.tilesprite = SPRITE_FLOOR
cell.walkable = True
cell.transparent = True
def carve_l_tunnel(
target_grid: mcrfpy.Grid,
start: tuple[int, int],
end: tuple[int, int]
) -> None:
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
carve_tunnel_horizontal(target_grid, x1, x2, y1)
carve_tunnel_vertical(target_grid, y1, y2, x2)
else:
carve_tunnel_vertical(target_grid, y1, y2, x1)
carve_tunnel_horizontal(target_grid, x1, x2, y2)
# =============================================================================
# Entity Management
# =============================================================================
def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity:
template = ENEMY_TEMPLATES[enemy_type]
enemy = mcrfpy.Entity(
grid_pos=(x, y),
texture=tex,
sprite_index=template["sprite"]
)
enemy.visible = False
target_grid.entities.append(enemy)
entity_data[enemy] = Fighter(
hp=template["hp"],
max_hp=template["hp"],
attack=template["attack"],
defense=template["defense"],
name=enemy_type.capitalize(),
is_player=False
)
return enemy
def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None:
num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM)
for _ in range(num_enemies):
inner_x, inner_y = room.inner
x = random.randint(inner_x.start, inner_x.stop - 1)
y = random.randint(inner_y.start, inner_y.stop - 1)
if is_position_occupied(target_grid, x, y):
continue
roll = random.random()
if roll < 0.6:
enemy_type = "goblin"
elif roll < 0.9:
enemy_type = "orc"
else:
enemy_type = "troll"
spawn_enemy(target_grid, x, y, enemy_type, tex)
def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity:
template = ITEM_TEMPLATES[item_type]
item_entity = mcrfpy.Entity(
grid_pos=(x, y),
texture=tex,
sprite_index=template["sprite"]
)
item_entity.visible = False
target_grid.entities.append(item_entity)
item_data[item_entity] = Item(
name=template["name"],
item_type=template["item_type"],
heal_amount=template.get("heal_amount", 0)
)
return item_entity
def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None:
num_items = random.randint(0, MAX_ITEMS_PER_ROOM)
for _ in range(num_items):
inner_x, inner_y = room.inner
x = random.randint(inner_x.start, inner_x.stop - 1)
y = random.randint(inner_y.start, inner_y.stop - 1)
if is_position_occupied(target_grid, x, y):
continue
spawn_item(target_grid, x, y, "health_potion", tex)
def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool:
for entity in target_grid.entities:
if int(entity.grid_x) == x and int(entity.grid_y) == y:
return True
return False
def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]:
for entity in target_grid.entities:
if entity in item_data:
if int(entity.grid_x) == x and int(entity.grid_y) == y:
return entity
return None
def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]:
for entity in target_grid.entities:
if entity == exclude:
continue
if int(entity.grid_x) == x and int(entity.grid_y) == y:
if entity in entity_data and entity_data[entity].is_alive:
return entity
return None
def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(entity)
break
if entity in entity_data:
del entity_data[entity]
def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(entity)
break
if entity in item_data:
del item_data[entity]
def clear_all_entities(target_grid: mcrfpy.Grid) -> None:
global entity_data, item_data
entities_to_remove = []
for entity in target_grid.entities:
if entity in entity_data and not entity_data[entity].is_player:
entities_to_remove.append(entity)
elif entity in item_data:
entities_to_remove.append(entity)
for entity in entities_to_remove:
if entity in entity_data:
del entity_data[entity]
if entity in item_data:
del item_data[entity]
for i, e in enumerate(target_grid.entities):
if e == entity:
target_grid.entities.remove(entity)
break
# =============================================================================
# Targeting System
# =============================================================================
def enter_targeting_mode() -> None:
"""Enter targeting mode for ranged attack."""
global game_mode, target_cursor, target_x, target_y, player, grid, texture
# Start at player position
target_x = int(player.grid_x)
target_y = int(player.grid_y)
# Create the targeting cursor
target_cursor = mcrfpy.Entity(
grid_pos=(target_x, target_y),
texture=texture,
sprite_index=SPRITE_CURSOR
)
grid.entities.append(target_cursor)
game_mode = GameMode.TARGETING
message_log.add("Targeting mode: Use arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO)
mode_display.update(game_mode)
def exit_targeting_mode() -> None:
"""Exit targeting mode without firing."""
global game_mode, target_cursor, grid
if target_cursor is not None:
# Remove cursor from grid
for i, e in enumerate(grid.entities):
if e == target_cursor:
grid.entities.remove(target_cursor)
break
target_cursor = None
game_mode = GameMode.NORMAL
mode_display.update(game_mode)
def move_cursor(dx: int, dy: int) -> None:
"""Move the targeting cursor."""
global target_x, target_y, target_cursor, grid, player
new_x = target_x + dx
new_y = target_y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if position is in FOV (can only target visible tiles)
if not grid.is_in_fov(new_x, new_y):
message_log.add("You cannot see that location.", COLOR_INVALID)
return
# Check range
player_x, player_y = int(player.grid_x), int(player.grid_y)
distance = abs(new_x - player_x) + abs(new_y - player_y)
if distance > RANGED_ATTACK_RANGE:
message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING)
return
# Move cursor
target_x = new_x
target_y = new_y
target_cursor.grid_x = target_x
target_cursor.grid_y = target_y
# Show info about target location
enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player)
if enemy and enemy in entity_data:
fighter = entity_data[enemy]
message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED)
def confirm_target() -> None:
"""Confirm the target and execute ranged attack."""
global game_mode, target_x, target_y, player, grid
# Check if targeting self
if target_x == int(player.grid_x) and target_y == int(player.grid_y):
message_log.add("You cannot target yourself!", COLOR_INVALID)
return
# Check for enemy at target
target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player)
if target_enemy is None or target_enemy not in entity_data:
message_log.add("No valid target at that location.", COLOR_INVALID)
return
# Perform ranged attack
perform_ranged_attack(target_enemy)
# Exit targeting mode
exit_targeting_mode()
# Enemies take their turn
enemy_turn()
update_ui()
def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None:
"""Execute a ranged attack on the target.
Args:
target_entity: The entity to attack
"""
global player, game_over
defender = entity_data.get(target_entity)
attacker = entity_data.get(player)
if defender is None or attacker is None:
return
# Ranged attacks deal fixed damage (ignores defense partially)
damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2)
defender.take_damage(damage)
message_log.add(
f"Your ranged attack hits the {defender.name} for {damage} damage!",
COLOR_RANGED
)
# Check for death
if not defender.is_alive:
handle_death(target_entity, defender)
# =============================================================================
# Combat System
# =============================================================================
def calculate_damage(attacker: Fighter, defender: Fighter) -> int:
return max(0, attacker.attack - defender.defense)
def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None:
global game_over
attacker = entity_data.get(attacker_entity)
defender = entity_data.get(defender_entity)
if attacker is None or defender is None:
return
damage = calculate_damage(attacker, defender)
defender.take_damage(damage)
if damage > 0:
if attacker.is_player:
message_log.add(
f"You hit the {defender.name} for {damage} damage!",
COLOR_PLAYER_ATTACK
)
else:
message_log.add(
f"The {attacker.name} hits you for {damage} damage!",
COLOR_ENEMY_ATTACK
)
else:
if attacker.is_player:
message_log.add(
f"You hit the {defender.name} but deal no damage.",
mcrfpy.Color(150, 150, 150)
)
else:
message_log.add(
f"The {attacker.name} hits but deals no damage.",
mcrfpy.Color(150, 150, 200)
)
if not defender.is_alive:
handle_death(defender_entity, defender)
update_ui()
def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None:
global game_over, grid
if fighter.is_player:
message_log.add("You have died!", COLOR_PLAYER_DEATH)
message_log.add("Press R to restart or Escape to quit.", COLOR_INFO)
game_over = True
entity.sprite_index = SPRITE_CORPSE
else:
message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH)
remove_entity(grid, entity)
# =============================================================================
# Item Actions
# =============================================================================
def pickup_item() -> bool:
global player, player_inventory, grid
px, py = int(player.grid_x), int(player.grid_y)
item_entity = get_item_at(grid, px, py)
if item_entity is None:
message_log.add("There is nothing to pick up here.", COLOR_INVALID)
return False
if player_inventory.is_full():
message_log.add("Your inventory is full!", COLOR_WARNING)
return False
item = item_data.get(item_entity)
if item is None:
return False
player_inventory.add(item)
remove_item_entity(grid, item_entity)
message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP)
update_ui()
return True
def use_item(index: int) -> bool:
global player, player_inventory
item = player_inventory.get(index)
if item is None:
message_log.add("Invalid item selection.", COLOR_INVALID)
return False
if item.item_type == "health_potion":
fighter = entity_data.get(player)
if fighter is None:
return False
if fighter.hp >= fighter.max_hp:
message_log.add("You are already at full health!", COLOR_WARNING)
return False
actual_heal = fighter.heal(item.heal_amount)
player_inventory.remove(index)
message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL)
update_ui()
return True
message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID)
return False
# =============================================================================
# Field of View
# =============================================================================
def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
global player, target_cursor
for entity in target_grid.entities:
if entity == player:
entity.visible = True
continue
# Targeting cursor is always visible during targeting mode
if entity == target_cursor:
entity.visible = True
continue
ex, ey = int(entity.grid_x), int(entity.grid_y)
entity.visible = target_grid.is_in_fov(ex, ey)
def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
target_grid.compute_fov((player_x, player_y), radius=FOV_RADIUS, algorithm=mcrfpy.FOV.SHADOW)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
if target_grid.is_in_fov(x, y):
mark_explored(x, y)
target_fov_layer.set((x, y), COLOR_VISIBLE)
elif is_explored(x, y):
target_fov_layer.set((x, y), COLOR_DISCOVERED)
else:
target_fov_layer.set((x, y), COLOR_UNKNOWN)
update_entity_visibility(target_grid)
# =============================================================================
# Movement and Actions
# =============================================================================
def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool:
if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT:
return False
if not target_grid.at(x, y).walkable:
return False
blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover)
if blocker is not None:
return False
return True
def try_move_or_attack(dx: int, dy: int) -> None:
global player, grid, fov_layer, game_over
if game_over:
return
px, py = int(player.grid_x), int(player.grid_y)
target_x = px + dx
target_y = py + dy
if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT:
return
blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player)
if blocker is not None:
perform_attack(player, blocker)
enemy_turn()
elif grid.at(target_x, target_y).walkable:
player.grid_x = target_x
player.grid_y = target_y
update_fov(grid, fov_layer, target_x, target_y)
enemy_turn()
update_ui()
# =============================================================================
# Enemy AI
# =============================================================================
def enemy_turn() -> None:
global player, grid, game_over
if game_over:
return
player_x, player_y = int(player.grid_x), int(player.grid_y)
enemies = []
for entity in grid.entities:
if entity == player:
continue
if entity in entity_data and entity_data[entity].is_alive:
enemies.append(entity)
for enemy in enemies:
fighter = entity_data.get(enemy)
if fighter is None or not fighter.is_alive:
continue
ex, ey = int(enemy.grid_x), int(enemy.grid_y)
if not grid.is_in_fov(ex, ey):
continue
dx = player_x - ex
dy = player_y - ey
if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0):
perform_attack(enemy, player)
else:
move_toward_player(enemy, ex, ey, player_x, player_y)
def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None:
global grid
dx = 0
dy = 0
if px < ex:
dx = -1
elif px > ex:
dx = 1
if py < ey:
dy = -1
elif py > ey:
dy = 1
new_x = ex + dx
new_y = ey + dy
if can_move_to(grid, new_x, new_y, enemy):
enemy.grid_x = new_x
enemy.grid_y = new_y
elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy):
enemy.grid_x = ex + dx
elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy):
enemy.grid_y = ey + dy
# =============================================================================
# UI Updates
# =============================================================================
def update_ui() -> None:
global player, health_bar, inventory_panel, player_inventory
if player in entity_data:
fighter = entity_data[player]
health_bar.update(fighter.hp, fighter.max_hp)
if player_inventory:
inventory_panel.update(player_inventory)
# =============================================================================
# Game Setup
# =============================================================================
# Create the scene
scene = mcrfpy.Scene("game")
# Load texture
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
# Create the grid
grid = mcrfpy.Grid(
pos=(20, GAME_AREA_Y),
size=(700, GAME_AREA_HEIGHT - 20),
grid_size=(GRID_WIDTH, GRID_HEIGHT),
texture=texture,
zoom=1.0
)
# Generate initial dungeon
fill_with_walls(grid)
init_explored()
rooms: list[RectangularRoom] = []
for _ in range(MAX_ROOMS):
room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
x = random.randint(1, GRID_WIDTH - room_width - 2)
y = random.randint(1, GRID_HEIGHT - room_height - 2)
new_room = RectangularRoom(x, y, room_width, room_height)
overlaps = False
for other_room in rooms:
if new_room.intersects(other_room):
overlaps = True
break
if overlaps:
continue
carve_room(grid, new_room)
if rooms:
carve_l_tunnel(grid, new_room.center, rooms[-1].center)
rooms.append(new_room)
# Get player starting position
if rooms:
player_start_x, player_start_y = rooms[0].center
else:
player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
# Add FOV layer
fov_layer = mcrfpy.ColorLayer(name="fov", z_index=-1)
grid.add_layer(fov_layer)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set((x, y), COLOR_UNKNOWN)
# Create the player
player = mcrfpy.Entity(
grid_pos=(player_start_x, player_start_y),
texture=texture,
sprite_index=SPRITE_PLAYER
)
grid.entities.append(player)
entity_data[player] = Fighter(
hp=30,
max_hp=30,
attack=5,
defense=2,
name="Player",
is_player=True
)
# Create player inventory
player_inventory = Inventory(capacity=10)
# Spawn enemies and items
for i, room in enumerate(rooms):
if i == 0:
continue
spawn_enemies_in_room(grid, room, texture)
spawn_items_in_room(grid, room, texture)
# Calculate initial FOV
update_fov(grid, fov_layer, player_start_x, player_start_y)
# Add grid to scene
scene.children.append(grid)
# =============================================================================
# Create UI Elements
# =============================================================================
# Title bar
title = mcrfpy.Caption(
pos=(20, 10),
text="Part 9: Ranged Combat"
)
title.fill_color = mcrfpy.Color(255, 255, 255)
title.font_size = 24
scene.children.append(title)
# Instructions
instructions = mcrfpy.Caption(
pos=(280, 15),
text="WASD: Move | F: Ranged attack | G: Pickup | 1-5: Use | R: Restart"
)
instructions.fill_color = mcrfpy.Color(180, 180, 180)
instructions.font_size = 14
scene.children.append(instructions)
# Health Bar
health_bar = HealthBar(
x=730,
y=10,
width=280,
height=30
)
health_bar.add_to_scene(scene)
# Mode Display
mode_display = ModeDisplay(x=20, y=40)
mode_display.add_to_scene(scene)
# Inventory Panel
inventory_panel = InventoryPanel(
x=730,
y=GAME_AREA_Y,
width=280,
height=150
)
inventory_panel.add_to_scene(scene)
# Message Log
message_log = MessageLog(
x=20,
y=768 - UI_BOTTOM_HEIGHT + 10,
width=990,
height=UI_BOTTOM_HEIGHT - 20,
max_messages=6
)
message_log.add_to_scene(scene)
# Initial messages
message_log.add("Welcome to the dungeon!", COLOR_INFO)
message_log.add("Press F to enter targeting mode for ranged attacks.", COLOR_INFO)
# Initialize UI
update_ui()
# =============================================================================
# Input Handling
# =============================================================================
def restart_game() -> None:
global player, grid, fov_layer, game_over, entity_data, item_data, rooms
global player_inventory, game_mode, target_cursor
# Exit targeting mode if active
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
game_over = False
game_mode = GameMode.NORMAL
entity_data.clear()
item_data.clear()
while len(grid.entities) > 0:
grid.entities.pop()
fill_with_walls(grid)
init_explored()
message_log.clear()
rooms = []
for _ in range(MAX_ROOMS):
room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
x = random.randint(1, GRID_WIDTH - room_width - 2)
y = random.randint(1, GRID_HEIGHT - room_height - 2)
new_room = RectangularRoom(x, y, room_width, room_height)
overlaps = False
for other_room in rooms:
if new_room.intersects(other_room):
overlaps = True
break
if overlaps:
continue
carve_room(grid, new_room)
if rooms:
carve_l_tunnel(grid, new_room.center, rooms[-1].center)
rooms.append(new_room)
if rooms:
new_x, new_y = rooms[0].center
else:
new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2
player = mcrfpy.Entity(
grid_pos=(new_x, new_y),
texture=texture,
sprite_index=SPRITE_PLAYER
)
grid.entities.append(player)
entity_data[player] = Fighter(
hp=30,
max_hp=30,
attack=5,
defense=2,
name="Player",
is_player=True
)
player_inventory = Inventory(capacity=10)
for i, room in enumerate(rooms):
if i == 0:
continue
spawn_enemies_in_room(grid, room, texture)
spawn_items_in_room(grid, room, texture)
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
fov_layer.set((x, y), COLOR_UNKNOWN)
update_fov(grid, fov_layer, new_x, new_y)
message_log.add("A new adventure begins!", COLOR_INFO)
mode_display.update(game_mode)
update_ui()
NUMBER_KEYS = [
mcrfpy.Key.NUM_1,
mcrfpy.Key.NUM_2,
mcrfpy.Key.NUM_3,
mcrfpy.Key.NUM_4,
mcrfpy.Key.NUM_5,
]
def handle_keys(key: mcrfpy.Key, action: mcrfpy.InputState) -> None:
global game_over, game_mode
if action != mcrfpy.InputState.PRESSED:
return
# Always allow restart and quit
if key == mcrfpy.Key.R:
restart_game()
return
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
message_log.add("Targeting cancelled.", COLOR_INFO)
return
else:
mcrfpy.exit()
return
if game_over:
return
# Handle input based on game mode
if game_mode == GameMode.TARGETING:
handle_targeting_input(key)
else:
handle_normal_input(key)
def handle_normal_input(key: mcrfpy.Key) -> None:
"""Handle input in normal game mode."""
# Movement
if key == mcrfpy.Key.W or key == mcrfpy.Key.UP:
try_move_or_attack(0, -1)
elif key == mcrfpy.Key.S or key == mcrfpy.Key.DOWN:
try_move_or_attack(0, 1)
elif key == mcrfpy.Key.A or key == mcrfpy.Key.LEFT:
try_move_or_attack(-1, 0)
elif key == mcrfpy.Key.D or key == mcrfpy.Key.RIGHT:
try_move_or_attack(1, 0)
# Ranged attack (enter targeting mode)
elif key == mcrfpy.Key.F:
enter_targeting_mode()
# Pickup
elif key == mcrfpy.Key.G or key == mcrfpy.Key.COMMA:
pickup_item()
# Use items
elif key in NUMBER_KEYS:
index = NUMBER_KEYS.index(key)
if use_item(index):
enemy_turn()
update_ui()
def handle_targeting_input(key: mcrfpy.Key) -> None:
"""Handle input in targeting mode."""
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
move_cursor(0, -1)
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
move_cursor(0, 1)
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
move_cursor(-1, 0)
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
move_cursor(1, 0)
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
confirm_target()
scene.on_key = handle_keys
# =============================================================================
# Start the Game
# =============================================================================
scene.activate()
print("Part 9 loaded! Press F to enter targeting mode for ranged attacks.")
Understanding the Code
Game Mode Enum
class GameMode(Enum):
NORMAL = "normal"
TARGETING = "targeting"
game_mode: GameMode = GameMode.NORMAL
Using an enum for game mode:
- Makes state explicit and documented
- Prevents typos compared to string comparisons
- Enables IDE autocompletion
Entering Targeting Mode
def enter_targeting_mode() -> None:
global game_mode, target_cursor, target_x, target_y
# Start at player position
target_x = int(player.grid_x)
target_y = int(player.grid_y)
# Create the targeting cursor
target_cursor = mcrfpy.Entity(
grid_pos=(target_x, target_y),
texture=texture,
sprite_index=SPRITE_CURSOR
)
grid.entities.append(target_cursor)
game_mode = GameMode.TARGETING
When entering targeting mode:
- Initialize cursor position at player location
- Create a visible cursor entity
- Change game mode
- Update UI to show targeting controls
Moving the Cursor
def move_cursor(dx: int, dy: int) -> None:
global target_x, target_y, target_cursor
new_x = target_x + dx
new_y = target_y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if position is in FOV
if not grid.is_in_fov(new_x, new_y):
message_log.add("You cannot see that location.", COLOR_INVALID)
return
# Check range
distance = abs(new_x - player_x) + abs(new_y - player_y)
if distance > RANGED_ATTACK_RANGE:
message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING)
return
# Move cursor
target_x = new_x
target_y = new_y
target_cursor.grid_x = target_x
target_cursor.grid_y = target_y
The cursor movement validates:
- Grid boundaries
- Line of sight (FOV)
- Maximum attack range
This provides immediate feedback about valid targets.
Confirming the Target
def confirm_target() -> None:
global game_mode
# Cannot target self
if target_x == int(player.grid_x) and target_y == int(player.grid_y):
message_log.add("You cannot target yourself!", COLOR_INVALID)
return
# Check for enemy at target
target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player)
if target_enemy is None:
message_log.add("No valid target at that location.", COLOR_INVALID)
return
# Execute attack
perform_ranged_attack(target_enemy)
exit_targeting_mode()
enemy_turn()
When confirming:
- Validate there is a valid target
- Execute the ranged attack
- Clean up targeting mode
- Give enemies their turn
Ranged Attack Damage
def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None:
defender = entity_data.get(target_entity)
# Ranged attacks deal fixed damage (ignores defense partially)
damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2)
defender.take_damage(damage)
message_log.add(
f"Your ranged attack hits the {defender.name} for {damage} damage!",
COLOR_RANGED
)
Ranged attacks use different damage calculation:
- Fixed base damage instead of player’s attack stat
- Defense is halved for ranged (arrows/magic bypass some armor)
- Always deals at least 1 damage
Mode-Based Input Handling
def handle_keys(key: mcrfpy.Key, action: mcrfpy.InputState) -> None:
if action != mcrfpy.InputState.PRESSED:
return
# Always allow these
if key == mcrfpy.Key.R:
restart_game()
return
if key == mcrfpy.Key.ESCAPE:
if game_mode == GameMode.TARGETING:
exit_targeting_mode()
return
else:
mcrfpy.exit()
return
if game_over:
return
# Route to mode-specific handler
if game_mode == GameMode.TARGETING:
handle_targeting_input(key)
else:
handle_normal_input(key)
The input handler:
- Handles universal keys first (restart, escape)
- Checks game over state
- Delegates to mode-specific handlers
This pattern scales well as you add more modes (inventory screen, menu, etc.).
Mode Display UI
class ModeDisplay:
def update(self, mode: GameMode) -> None:
if mode == GameMode.NORMAL:
self.caption.text = "[NORMAL MODE] - F: Ranged attack"
self.caption.fill_color = mcrfpy.Color(100, 255, 100)
elif mode == GameMode.TARGETING:
self.caption.text = "[TARGETING] - Arrows: Move, Enter: Fire, Esc: Cancel"
self.caption.fill_color = mcrfpy.Color(255, 255, 100)
Clear visual feedback about the current mode helps players understand what controls are active.
Cursor Visibility
def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
for entity in target_grid.entities:
if entity == player:
entity.visible = True
continue
# Targeting cursor is always visible
if entity == target_cursor:
entity.visible = True
continue
ex, ey = int(entity.grid_x), int(entity.grid_y)
entity.visible = target_grid.is_in_fov(ex, ey)
The targeting cursor is always visible (it is a UI element, not a game entity).
Targeting System Flow
Player presses F
|
v
enter_targeting_mode()
- Create cursor at player position
- Set game_mode = TARGETING
- Show targeting instructions
|
v
Player uses arrows
|
v
move_cursor(dx, dy)
- Validate: bounds, FOV, range
- Move cursor if valid
- Show target info
|
v
Player presses Enter
|
v
confirm_target()
- Validate: enemy at position
- perform_ranged_attack()
- exit_targeting_mode()
- enemy_turn()
Try This
- Variable range by weapon: Different weapons have different ranges:
# In player data equipped_weapon = {"name": "Shortbow", "range": 5, "damage": 4} equipped_weapon = {"name": "Longbow", "range": 8, "damage": 3} - Limited ammunition: Track and consume arrows:
arrows = 20 def perform_ranged_attack(target): global arrows if arrows <= 0: message_log.add("Out of arrows!", COLOR_INVALID) return False arrows -= 1 # ... rest of attack - Area of effect: Hit multiple enemies in a radius:
def get_enemies_in_radius(x, y, radius): enemies = [] for entity in grid.entities: if entity in entity_data and not entity_data[entity].is_player: dist = abs(int(entity.grid_x) - x) + abs(int(entity.grid_y) - y) if dist <= radius: enemies.append(entity) return enemies -
Target highlighting: Color the cursor differently when over a valid target
- Path visualization: Show the projectile path from player to target
Challenge: Scroll of Fireball
Add a fireball scroll that damages all enemies in an area:
FIREBALL_RADIUS = 2
FIREBALL_DAMAGE = 8
def use_fireball_scroll() -> None:
"""Enter targeting mode for fireball."""
global targeting_callback
targeting_callback = execute_fireball
enter_targeting_mode()
message_log.add("Select fireball target location.", COLOR_WARNING)
def execute_fireball(x: int, y: int) -> None:
"""Execute fireball at target location."""
enemies_hit = get_enemies_in_radius(x, y, FIREBALL_RADIUS)
if not enemies_hit:
message_log.add("The fireball explodes harmlessly.", COLOR_INFO)
return
for enemy in enemies_hit:
if enemy in entity_data:
entity_data[enemy].take_damage(FIREBALL_DAMAGE)
message_log.add(
f"The {entity_data[enemy].name} is burned for {FIREBALL_DAMAGE}!",
COLOR_RANGED
)
if not entity_data[enemy].is_alive:
handle_death(enemy, entity_data[enemy])
Challenge: Line-of-Fire Check
Ensure projectiles cannot pass through walls:
def has_clear_line(x1: int, y1: int, x2: int, y2: int) -> bool:
"""Check if there is a clear line between two points."""
# Use Bresenham's line algorithm
points = get_line_points(x1, y1, x2, y2)
for x, y in points[1:-1]: # Skip start and end
if not grid.at(x, y).transparent:
return False
return True
Common Mistakes
-
Forgetting to remove cursor: Always clean up the cursor entity when exiting targeting mode
-
Not validating FOV: Players should only target what they can see
-
Missing range check: Without range limits, ranged attacks become too powerful
-
Cursor blocking movement: The cursor entity should not block enemy movement or targeting
-
Mode confusion: Clear visual feedback about current mode prevents player confusion
-
Turn consumption: Decide if cancelled targeting consumes a turn (usually it should not)
Targeting Modes for Different Actions
This targeting system can be reused for many actions:
| Action | Target Type | Validation |
|---|---|---|
| Ranged attack | Single enemy | In FOV, in range, enemy exists |
| Fireball | Location | In FOV, in range |
| Teleport | Location | In FOV, walkable, not occupied |
| Examine | Any | In FOV |
| Throw item | Location | In FOV, in range |
What is Next
Congratulations! You now have a complete roguelike with:
- Procedural dungeon generation
- Field of view and exploration
- Enemies with AI
- Melee and ranged combat
- Items and inventory
- A polished UI
From here, you could add:
- Multiple dungeon levels (stairs)
- More enemy types
- Equipment (weapons, armor)
- Character progression (XP, levels)
- Save/load game state
- More item types (scrolls, wands)
The foundation you have built supports all of these features. Happy roguelike development!
Complete Code Reference
The complete code is shown above. Key additions from Part 8:
- GameMode enum: Tracks normal vs targeting mode
- enter_targeting_mode(): Creates cursor and switches mode
- exit_targeting_mode(): Removes cursor and restores normal mode
- move_cursor(): Validates and moves the targeting cursor
- confirm_target(): Executes ranged attack on selected target
- perform_ranged_attack(): Different damage calculation for ranged
- handle_targeting_input(): Mode-specific input handler
- ModeDisplay: Shows current mode and controls