User Interface Systems and Components
This document details Pyrite’s UI architecture, component hierarchy, event handling, and rendering pipeline. The UI spans from in-game HUD (hotbar, health bar) to menus (main menu, inventory, options).
UI Architecture Overview
Rendering Pipeline:
Main Loop
↓
[Update Phase]
→ Player input events
→ UI component updates (hover states, animations)
→ Inventory logic
↓
[Render Phase]
→ Disable depth test
→ Render world (if in-game)
→ Render UI layer (depth disabled)
→ Render text overlay (if applicable)
→ Re-enable depth test
Screen Coordinate System:
Normalized Device Coordinates (NDC): -1 to 1 in both X and Y axes
Calculate screen pos: screen_x = (pixel_x / WIN_WIDTH) * 2 - 1
UI shaders use: gl_Position = vec4(screen_x, screen_y, 0, 1) (orthographic projection)
Core UI Component Base Class
UIComponent (Abstract Base)
class UIComponent:
def __init__(self, pos, size):
self.position: (float, float) = pos # Top-left in NDC
self.size: (float, float) = size # Width, height
self.visible: bool = True
self.mouse_over: bool = False
self.clicked: bool = False
def update(self, delta_time):
# Update animations, hover states, etc.
pass
def render(self, ctx, program):
# Draw to screen (or skip if not visible)
pass
def handle_event(self, event):
# Respond to mouse/keyboard
pass
def contains_point(self, x, y) -> bool:
# Check if (x, y) is within component bounds
return (self.position[0] <= x <= self.position[0] + self.size[0] and
self.position[1] <= y <= self.position[1] + self.size[1])
In-Game HUD Components
1. Crosshair
Simple ‘+’ rendered at screen center. Always visible in-game.
class Crosshair(UIComponent):
def __init__(self):
super().__init__((0, 0), (0.02, 0.02)) # Small quad at center
self.color = (1, 1, 1, 0.5) # White, semi-transparent
def render(self, ctx, program):
# Draw 2 quads: horizontal and vertical lines forming '+'
# Use ui_color shader to render simple geometry
2. Hotbar
9 slots at bottom of screen, showing held items with selection highlight.
class Hotbar(UIComponent):
def __init__(self, player):
super().__init__((-0.45, -0.95), (0.9, 0.08)) # Bottom center
self.player = player
self.slots = [] # Array of slot quads
self.selected_index = 0
def update(self, delta_time):
# Update selection when player changes hotbar_index
self.selected_index = player.hotbar_index
def render(self, ctx, program):
# Render 9 slot backgrounds
for i in range(9):
slot_pos = (-0.4 + i * 0.1, -0.95)
slot_size = (0.08, 0.08)
# Render background quad (gray for unselected, bright for selected)
color = (1, 1, 1, 0.8) if i == selected_index else (0.5, 0.5, 0.5, 0.8)
render_quad(slot_pos, slot_size, color)
# Render held item icon
voxel_id, count = player.inventory[i], player.inventory_counts[i]
if voxel_id > 0:
render_item_icon(slot_pos, voxel_id)
render_count_text(slot_pos, count)
3. Health/Hunger/Oxygen Bars
Status indicators above hotbar.
class HealthBar(UIComponent):
def __init__(self, player):
super().__init__((-0.45, -1.05), (0.09, 0.04)) # Per-heart display
self.player = player
def render(self, ctx, program):
# Render 10 hearts (2 per full health point)
for i in range(10):
heart_pos = (-0.45 + i * 0.045, -1.05)
health_fill = player.health / 20.0
# Full heart, half heart, or empty heart based on fill
render_heart(heart_pos, health_fill)
4. Debug Overlay (F3)
Shows FPS, coords, chunk, facing direction, time, target block.
class DebugOverlay(UIComponent):
def __init__(self, app, player):
super().__init__((-0.95, -0.95), (0.4, 0.3))
self.app = app
self.player = player
self.update_interval = 0.25 # ms
self.last_update = 0
def update(self, delta_time):
self.last_update += delta_time
if self.last_update >= self.update_interval:
# Gather data
fps = self.app.clock.get_fps()
pos = player.feet_pos
chunk = (int(pos.x // 48), int(pos.z // 48))
facing = compute_facing_direction(player.yaw)
time = world.session_time
self.debug_text = f"""
FPS: {fps:.1f}
XYZ: {pos.x:.1f} {pos.y:.1f} {pos.z:.1f}
Chunk: {chunk}
Facing: {facing}
Time: {time:.0f}
"""
self.last_update = 0
def render(self, ctx, program):
render_text(self.position, self.debug_text, font_size=12)
Layout Metrics (Slot Grid)
Inventory Grid (36 main slots + 9 hotbar):
Hotbar (Bottom, 9 slots)
[0] [1] [2] [3] [4] [5] [6] [7] [8]
Main (3 rows, 9 slots each)
[9 ] [10] [11] [12] [13] [14] [15] [16] [17]
[18] [19] [20] [21] [22] [23] [24] [25] [26]
[27] [28] [29] [30] [31] [32] [33] [34] [35]
Crafting (2x2 grid + output)
[36] [37] [40] (output)
[38] [39]
3x3 Slot Size: Typically 0.08 x 0.08 in NDC
Slot Position Formula:
def compute_slot_positions():
slot_size = 0.08
spacing = 0.02
positions = {}
# Hotbar (bottom, centered)
for i in range(9):
x = -0.35 + i * (slot_size + spacing)
y = -0.05
positions[i] = ((x, y), (slot_size, slot_size))
# Main inventory (above hotbar)
for row in range(3):
for col in range(9):
i = 9 + row * 9 + col
x = -0.35 + col * (slot_size + spacing)
y = 0.15 + row * (slot_size + spacing)
positions[i] = ((x, y), (slot_size, slot_size))
# Crafting grid
craft_positions = [36, 37, 38, 39, 40]
craft_x = 0.30
craft_y = 0.25
craft_slot_size = 0.06
# 2x2 grid
positions[36] = ((craft_x, craft_y), (craft_slot_size, craft_slot_size))
positions[37] = ((craft_x + 0.08, craft_y), (craft_slot_size, craft_slot_size))
positions[38] = ((craft_x, craft_y + 0.08), (craft_slot_size, craft_slot_size))
positions[39] = ((craft_x + 0.08, craft_y + 0.08), (craft_slot_size, craft_slot_size))
# Output (larger, to the right)
positions[40] = ((craft_x + 0.20, craft_y + 0.04), (0.08, 0.08))
return positions
Transition Animations
Menu Slide-In/Out (cubic easing):
class MenuTransition:
def __init__(self, duration=0.3):
self.duration = duration
self.elapsed = 0
self.complete = False
def update(self, delta_time):
self.elapsed += delta_time
if self.elapsed >= self.duration:
self.complete = True
self.elapsed = self.duration
def get_progress(self):
# Cubic easing-in
t = self.elapsed / self.duration
return t * t * t
def get_offset(self, full_offset):
# Use progress for smooth slide
return full_offset * (1.0 - self.get_progress())
Integration with Main Loop
Order of Operations (per frame):
Event Handling: - Poll pygame events (mouse, keyboard) - Dispatch to active UI state handler
Update: - Update all visible UI components - Update crafting logic if inventory open
Render: - Disable depth test - Render UI components in order (background → middle → foreground) - Render text overlay - Re-enable depth test
pseudocode:
def main_loop():
while running:
for event in pygame.event.get():
if ui_state == 'IN_GAME':
player.handle_event(event)
if event.type == KEYDOWN and event.key == K_e:
ui_state = 'INVENTORY'
elif ui_state == 'INVENTORY':
inventory_ui.handle_event(event)
if event.type == KEYDOWN and event.key == K_e:
ui_state = 'IN_GAME'
elif ui_state == 'MAIN_MENU':
main_menu.handle_event(event)
# Update
if ui_state == 'IN_GAME':
player.update(delta_time)
hud.update(delta_time)
elif ui_state == 'INVENTORY':
inventory_ui.update_crafting()
inventory_ui.update(delta_time)
# Render
glDisable(GL_DEPTH_TEST)
if ui_state == 'IN_GAME':
crosshair.render()
hotbar.render()
health_bar.render()
elif ui_state == 'INVENTORY':
inventory_ui.render()
glEnable(GL_DEPTH_TEST)