In Part 6, we implemented combat with a simple HP display and message log. Now we will build a proper user interface with a visual health bar, a scrolling message log, and dungeon information. Good UI makes the difference between a confusing game and an engaging one.
A roguelike UI should provide information at a glance:
| Element | Purpose | Location |
|---|---|---|
| Health Bar | Current HP as visual bar | Top or bottom |
| Message Log | Recent events | Below game area |
| Dungeon Level | Current depth | Corner |
| Stats | Attack, defense, etc. | Side panel |
We will build each component step by step.
Create a file called part_07_ui.py:
"""McRogueFace Tutorial - Part 7: User Interface
Create a polished UI with health bar, message log, and stats display.
"""
import mcrfpy
import random
from dataclasses import dataclass
from typing import Optional
# =============================================================================
# Constants
# =============================================================================
# Sprite indices for CP437 tileset
SPRITE_WALL = 35 # '#' - wall
SPRITE_FLOOR = 46 # '.' - floor
SPRITE_PLAYER = 64 # '@' - player
SPRITE_CORPSE = 37 # '%' - remains
# 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
# FOV settings
FOV_RADIUS = 8
# 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_INFO = mcrfpy.Color(100, 100, 255)
COLOR_WARNING = mcrfpy.Color(255, 200, 50)
# 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
# =============================================================================
# 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
# =============================================================================
# Enemy Templates
# =============================================================================
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):
"""Create a new message log.
Args:
x: X position of the log
y: Y position of the log
width: Width of the log area
height: Height of the log area
max_messages: Maximum number of messages to display
"""
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] = []
# Create the background frame
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)
# Create caption for each message line
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:
"""Add the message log UI elements to a scene."""
scene.children.append(self.frame)
for caption in self.captions:
scene.children.append(caption)
def add(self, text: str, color: mcrfpy.Color = None) -> None:
"""Add a message to the log.
Args:
text: The message text
color: Optional color (defaults to white)
"""
if color is None:
color = mcrfpy.Color(200, 200, 200)
self.messages.append((text, color))
# Keep only the most recent messages
while len(self.messages) > self.max_messages:
self.messages.pop(0)
self._refresh()
def _refresh(self) -> None:
"""Update the caption displays with current messages."""
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:
"""Clear all messages."""
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):
"""Create a new health bar.
Args:
x: X position
y: Y position
width: Total width of the health bar
height: Height of the health bar
"""
self.x = x
self.y = y
self.width = width
self.height = height
self.max_hp = 30
self.current_hp = 30
# Background frame (dark red - shows when damaged)
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)
# Foreground frame (the actual health - shrinks as HP decreases)
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
# HP text label
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:
"""Add the health bar UI elements to a scene."""
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:
"""Update the health bar display.
Args:
current_hp: Current HP value
max_hp: Maximum HP value
"""
self.current_hp = current_hp
self.max_hp = max_hp
# Calculate fill percentage
percent = max(0, current_hp / max_hp) if max_hp > 0 else 0
# Update the foreground width
inner_width = self.width - 4
self.fg_frame.resize(int(inner_width * percent), self.height - 4)
# Update the label
self.label.text = f"HP: {current_hp}/{max_hp}"
# Update color based on health percentage
if percent > 0.6:
self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) # Green
elif percent > 0.3:
self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) # Yellow
else:
self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) # Red
# =============================================================================
# Stats Panel
# =============================================================================
class StatsPanel:
"""A panel displaying player stats and dungeon info."""
def __init__(self, x: int, y: int, width: int, height: int):
"""Create a new stats panel.
Args:
x: X position
y: Y position
width: Panel width
height: Panel height
"""
self.x = x
self.y = y
self.width = width
self.height = height
# Background frame
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)
# Dungeon level caption
self.level_caption = mcrfpy.Caption(
pos=(x + 10, y + 10),
text="Dungeon Level: 1"
)
self.level_caption.font_size = 16
self.level_caption.fill_color = mcrfpy.Color(200, 200, 255)
# Attack stat caption
self.attack_caption = mcrfpy.Caption(
pos=(x + 10, y + 35),
text="Attack: 5"
)
self.attack_caption.font_size = 14
self.attack_caption.fill_color = mcrfpy.Color(255, 200, 150)
# Defense stat caption
self.defense_caption = mcrfpy.Caption(
pos=(x + 120, y + 35),
text="Defense: 2"
)
self.defense_caption.font_size = 14
self.defense_caption.fill_color = mcrfpy.Color(150, 200, 255)
def add_to_scene(self, scene: mcrfpy.Scene) -> None:
"""Add the stats panel UI elements to a scene."""
scene.children.append(self.frame)
scene.children.append(self.level_caption)
scene.children.append(self.attack_caption)
scene.children.append(self.defense_caption)
def update(self, dungeon_level: int, attack: int, defense: int) -> None:
"""Update the stats panel display.
Args:
dungeon_level: Current dungeon level
attack: Player attack stat
defense: Player defense stat
"""
self.level_caption.text = f"Dungeon Level: {dungeon_level}"
self.attack_caption.text = f"Attack: {attack}"
self.defense_caption.text = f"Defense: {defense}"
# =============================================================================
# Global State
# =============================================================================
entity_data: dict[mcrfpy.Entity, Fighter] = {}
player: Optional[mcrfpy.Entity] = None
grid: Optional[mcrfpy.Grid] = None
fov_layer = None
texture: Optional[mcrfpy.Texture] = None
game_over: bool = False
dungeon_level: int = 1
# UI components
message_log: Optional[MessageLog] = None
health_bar: Optional[HealthBar] = None
stats_panel: Optional[StatsPanel] = 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 get_entity_at(target_grid, x, y) is not None:
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 get_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]:
for entity in target_grid.entities:
if int(entity.x) == x and int(entity.y) == y:
if entity in entity_data:
if entity_data[entity].is_alive:
return entity
else:
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.x) == x and int(entity.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(i)
break
if entity in entity_data:
del entity_data[entity]
def clear_enemies(target_grid: mcrfpy.Grid) -> None:
enemies_to_remove = []
for entity in target_grid.entities:
if entity in entity_data and not entity_data[entity].is_player:
enemies_to_remove.append(entity)
for enemy in enemies_to_remove:
remove_entity(target_grid, enemy)
# =============================================================================
# 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)
# =============================================================================
# Field of View
# =============================================================================
def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
global player
for entity in target_grid.entities:
if entity == player:
entity.visible = True
continue
ex, ey = int(entity.x), int(entity.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, FOV_RADIUS, 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.x), int(player.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.x = target_x
player.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.x), int(player.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.x), int(enemy.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.x = new_x
enemy.y = new_y
elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy):
enemy.x = ex + dx
elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy):
enemy.y = ey + dy
# =============================================================================
# UI Updates
# =============================================================================
def update_ui() -> None:
"""Update all UI components."""
global player, health_bar, stats_panel, dungeon_level
if player in entity_data:
fighter = entity_data[player]
health_bar.update(fighter.hp, fighter.max_hp)
stats_panel.update(dungeon_level, fighter.attack, fighter.defense)
# =============================================================================
# Game Setup
# =============================================================================
# Create the scene
scene = mcrfpy.Scene("game")
# Load texture
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
# Create the grid (positioned to leave room for UI)
grid = mcrfpy.Grid(
pos=(20, GAME_AREA_Y),
size=(800, GAME_AREA_HEIGHT - 20),
grid_size=(GRID_WIDTH, GRID_HEIGHT),
texture=texture,
zoom=1.0
)
# Generate initial dungeon structure
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 = grid.add_layer("color", z_index=-1)
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
)
# Spawn enemies
for i, room in enumerate(rooms):
if i == 0:
continue
spawn_enemies_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 7: User Interface"
)
title.fill_color = mcrfpy.Color(255, 255, 255)
title.font_size = 24
scene.children.append(title)
# Instructions
instructions = mcrfpy.Caption(
pos=(300, 15),
text="WASD/Arrows: Move/Attack | R: Restart | Escape: Quit"
)
instructions.fill_color = mcrfpy.Color(180, 180, 180)
instructions.font_size = 14
scene.children.append(instructions)
# Health Bar (top right area)
health_bar = HealthBar(
x=700,
y=10,
width=300,
height=30
)
health_bar.add_to_scene(scene)
# Stats Panel (below health bar)
stats_panel = StatsPanel(
x=830,
y=GAME_AREA_Y,
width=180,
height=80
)
stats_panel.add_to_scene(scene)
# Message Log (bottom of screen)
message_log = MessageLog(
x=20,
y=768 - UI_BOTTOM_HEIGHT + 10,
width=800,
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("Find and defeat all enemies to progress.", COLOR_INFO)
# Initialize UI
update_ui()
# =============================================================================
# Input Handling
# =============================================================================
def restart_game() -> None:
"""Restart the game with a new dungeon."""
global player, grid, fov_layer, game_over, entity_data, rooms, dungeon_level
game_over = False
dungeon_level = 1
entity_data.clear()
while len(grid.entities) > 0:
grid.entities.remove(0)
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
)
for i, room in enumerate(rooms):
if i == 0:
continue
spawn_enemies_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)
update_ui()
def handle_keys(key: str, action: str) -> None:
global game_over
if action != "start":
return
if key == "R":
restart_game()
return
if key == "Escape":
mcrfpy.exit()
return
if game_over:
return
if key == "W" or key == "Up":
try_move_or_attack(0, -1)
elif key == "S" or key == "Down":
try_move_or_attack(0, 1)
elif key == "A" or key == "Left":
try_move_or_attack(-1, 0)
elif key == "D" or key == "Right":
try_move_or_attack(1, 0)
scene.on_key = handle_keys
# =============================================================================
# Start the Game
# =============================================================================
scene.activate()
print("Part 7 loaded! Notice the improved UI with health bar and message log.")
class HealthBar:
def __init__(self, x: int, y: int, width: int, height: int):
# Background frame (shows "empty" health as dark red)
self.bg_frame = mcrfpy.Frame(
pos=(x, y),
size=(width, height)
)
self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0)
# Foreground frame (shows current health)
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)
# Text label showing numeric HP
self.label = mcrfpy.Caption(...)
The health bar uses two nested Frames:
This layered approach creates a visual bar that shrinks as health decreases.
def update(self, current_hp: int, max_hp: int) -> None:
percent = max(0, current_hp / max_hp) if max_hp > 0 else 0
# Shrink the foreground width
inner_width = self.width - 4
self.fg_frame.resize(int(inner_width * percent), self.height - 4)
# Color based on percentage
if percent > 0.6:
self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) # Green
elif percent > 0.3:
self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) # Yellow
else:
self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) # Red
The color transitions provide at-a-glance health information:
class MessageLog:
def __init__(self, x, y, width, height, max_messages=6):
self.messages = [] # List of (text, color) tuples
self.captions = [] # Pre-created Caption objects
# Background frame
self.frame = mcrfpy.Frame(...)
# One caption per line
for i in range(max_messages):
caption = mcrfpy.Caption(
pos=(x + 10, y + 5 + i * line_height),
text=""
)
self.captions.append(caption)
We pre-create a fixed number of Caption objects. When messages are added, we update their text and color rather than creating/destroying objects. This is more efficient and avoids flickering.
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) # Remove oldest
self._refresh()
Messages are stored as tuples of (text, color). When we exceed the maximum, the oldest message is removed. The _refresh() method updates all the Caption objects.
COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) # Gray-white
COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) # Light red
COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) # Bright red
COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) # Green
COLOR_HEAL = mcrfpy.Color(100, 255, 100) # Green
COLOR_INFO = mcrfpy.Color(100, 100, 255) # Blue
COLOR_WARNING = mcrfpy.Color(255, 200, 50) # Yellow
Color-coding messages by type helps players quickly scan for important information:
class StatsPanel:
def __init__(self, x, y, width, height):
self.frame = mcrfpy.Frame(...)
self.level_caption = mcrfpy.Caption(text="Dungeon Level: 1")
self.attack_caption = mcrfpy.Caption(text="Attack: 5")
self.defense_caption = mcrfpy.Caption(text="Defense: 2")
def update(self, dungeon_level: int, attack: int, defense: int) -> None:
self.level_caption.text = f"Dungeon Level: {dungeon_level}"
self.attack_caption.text = f"Attack: {attack}"
self.defense_caption.text = f"Defense: {defense}"
The stats panel groups related information. Using a Frame as a container provides visual separation from other UI elements.
UI_TOP_HEIGHT = 60
UI_BOTTOM_HEIGHT = 150
GAME_AREA_Y = UI_TOP_HEIGHT
GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT
Defining layout constants makes it easy to adjust the UI. The game area is positioned between the top UI (health bar, title) and bottom UI (message log).
def update_ui() -> None:
"""Update all UI components."""
global player, health_bar, stats_panel, dungeon_level
if player in entity_data:
fighter = entity_data[player]
health_bar.update(fighter.hp, fighter.max_hp)
stats_panel.update(dungeon_level, fighter.attack, fighter.defense)
A single function updates all UI elements. Call this after any action that might change game state.
Scene
|
+-- Grid (game area)
|
+-- Title Caption
|
+-- Instructions Caption
|
+-- HealthBar
| +-- bg_frame (background)
| +-- fg_frame (foreground)
| +-- label (text)
|
+-- StatsPanel
| +-- frame (container)
| +-- level_caption
| +-- attack_caption
| +-- defense_caption
|
+-- MessageLog
+-- frame (container)
+-- caption[0..max_messages]
Each UI component is self-contained, with its own add_to_scene() method that adds all necessary elements.
Add an experience bar: Create a second bar showing XP progress to next level
Show enemy health: Display a small health indicator over damaged enemies
turn_count = 0
def add_message(text, color):
message_log.add(f"[{turn_count}] {text}", color)
Minimap: Create a small overview of explored areas using a second, zoomed-out Grid
Create a health bar that animates smoothly when taking damage:
class AnimatedHealthBar(HealthBar):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.displayed_hp = 30
self.target_hp = 30
def update(self, current_hp: int, max_hp: int) -> None:
self.target_hp = current_hp
self.max_hp = max_hp
# Actual bar update happens in animate()
def animate(self) -> None:
"""Call this each frame to animate the bar."""
if self.displayed_hp > self.target_hp:
self.displayed_hp -= 1 # Drain effect
self._update_display()
# Use a timer to animate
mcrfpy.setTimer("hp_animate", lambda: health_bar.animate(), 50)
Save the message log to a file for later review:
def save_combat_log(filename: str) -> None:
with open(filename, "w") as f:
for text, color in message_log.messages:
f.write(f"{text}\n")
Forgetting add_to_scene(): UI components must be added to the scene to be visible
Wrong z-order: If UI appears behind the grid, check that you add UI elements after the grid
Not updating after changes: Always call update_ui() after HP or stat changes
Hardcoded positions: Use constants for layout to make adjustments easier
Too many messages: The log can scroll away important info - consider highlighting critical messages
| Property | Type | Purpose |
|---|---|---|
pos |
tuple | (x, y) position in pixels |
size |
tuple | (width, height) in pixels |
fill_color |
Color | Background color |
outline |
int | Border thickness (0 = none) |
outline_color |
Color | Border color |
In Part 8, we will add items and inventory. You will learn:
Continue to Part 8: Items and Inventory
The complete code is shown above. Key additions from Part 6: