Implement UI Framework With Egui For ARPG
Hey guys! Let's dive into the exciting world of UI development for our ARPG project! This article outlines the plan to implement a complete UI framework using egui, ensuring our game is not only fun but also super user-friendly. We're talking smooth interactions, clear information, and a UI that doesn't break the immersion. Let's get started!
User Story
As a player who loves interacting with deep ARPG systems,
I need a UI that's responsive and intuitive, giving me clear info and seamless interactions,
So that I can efficiently manage my inventory, skills, stats, and game settings without losing my flow or immersion.
Value Proposition
The UI is the bridge between what players want to do and how the game mechanics work. In ARPGs, where you're juggling tons of systems at once, the UI can make or break the experience. A well-designed UI framework offers:
- Information Clarity: Presenting complex stats in a way that's easy to grasp.
- Efficient Workflows: Making common actions quick and painless.
- Visual Feedback: Responding instantly to your every move.
- Customization: Letting you arrange the UI to suit your style.
- Performance: Ensuring UI updates don't tank your framerate.
The difference between a good ARPG and a great one often boils down to UI polish and responsiveness. It’s about making the player feel in control and informed every step of the way.
Acceptance Criteria
Let's break down what we need to nail for this UI framework:
- [ ] Core UI Architecture:
- [ ] Egui integration with Bevy rendering: We need egui playing nice with our Bevy engine.
- [ ] UI state management system: A robust way to keep track of what's happening in the UI.
- [ ] Theme and styling system: So we can make the UI look amazing.
- [ ] Layout management with anchoring: Ensuring elements stay where they should, no matter the screen size.
- [ ] UI scaling for different resolutions: A UI that looks good on any monitor.
- [ ] Input handling and focus management: Making sure clicks and key presses go where they're supposed to.
- [ ] HUD Elements:
- [ ] Health/Mana/Resource globes: Gotta keep an eye on those vital stats!
- [ ] Skill bar with cooldown visualization: Know when you can unleash your powers again.
- [ ] Buff/debuff icons with timers: See what's helping or hurting you, and for how long.
- [ ] Experience bar with level indicator: Track your progress and know when you're leveling up.
- [ ] Mini-map with fog of war: Explore the world without getting lost.
- [ ] Quest tracker overlay: Keep those quests in check.
- [ ] Windows and Panels:
- [ ] Inventory grid with drag-and-drop: Because who doesn't love a good inventory system?
- [ ] Character stats panel: Dive deep into your character's abilities.
- [ ] Skill tree interface: Plan your build and become a powerhouse.
- [ ] Quest log with categories: Organize your adventures.
- [ ] Settings menu with tabs: Tweak the game to your liking.
- [ ] Map overlay system: Get the lay of the land.
- [ ] Interactive Elements:
- [ ] Tooltips with comparison: Know what that item really does.
- [ ] Context menus: Quick actions at your fingertips.
- [ ] Modal dialogs: Important messages that need your attention.
- [ ] Notification system: Stay informed about game events.
- [ ] Chat interface: Connect with other players.
- [ ] Trade window: Share the loot!
- [ ] Visual Polish:
- [ ] Smooth animations and transitions: Because nobody likes a clunky UI.
- [ ] Particle effects on UI elements: Make things pop!
- [ ] Sound feedback for interactions: Clicks and clacks that feel right.
- [ ] Visual states (hover, pressed, disabled): Know when you're interacting with something.
- [ ] Loading screens with tips: Make loading times a little less painful.
Technical Specifications
Let’s get technical and peek at some code snippets! This gives you an idea of how we're structuring things.
UI Framework Architecture
// crates/hephaestus_ui/src/lib.rs
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts, EguiPlugin};
use serde::{Deserialize, Serialize};
pub struct HephaestusUIPlugin;
impl Plugin for HephaestusUIPlugin {
fn build(&self, app: &mut App) {
app
.add_plugins(EguiPlugin)
.init_resource::<UIState>()
.init_resource::<UITheme>()
.init_resource::<WindowManager>()
.init_resource::<TooltipSystem>()
.add_event::<UIEvent>()
// UI Systems
.add_systems(Startup, (
setup_ui_theme,
load_ui_layouts,
initialize_windows,
))
.add_systems(Update, (
// Input layer
handle_ui_input,
update_drag_drop,
// Window management
update_window_positions,
handle_window_focus,
// HUD updates
update_health_mana_display,
update_skill_bar,
update_buff_icons,
update_minimap,
// Interactive windows
render_inventory_window,
render_character_panel,
render_skill_tree,
render_settings_menu,
// Overlay systems
render_tooltips,
render_notifications,
show_damage_numbers,
).chain());
}
}
/// Central UI state management
#[derive(Resource, Default)]
pub struct UIState {
pub open_windows: HashSet<WindowType>,
pub focused_window: Option<WindowType>,
pub hud_visible: bool,
pub ui_scale: f32,
pub drag_data: Option<DragData>,
pub tooltip_data: Option<TooltipData>,
pub notifications: VecDeque<Notification>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WindowType {
Inventory,
Character,
SkillTree,
QuestLog,
Map,
Settings,
Stash,
Trade,
}
/// UI Theme configuration
#[derive(Resource, Serialize, Deserialize)]
pub struct UITheme {
pub colors: ColorScheme,
pub fonts: FontSettings,
pub animations: AnimationSettings,
pub sounds: UISounds,
}
#[derive(Serialize, Deserialize)]
pub struct ColorScheme {
pub background: Color32,
pub panel: Color32,
pub text: Color32,
pub text_secondary: Color32,
pub accent: Color32,
pub success: Color32,
pub warning: Color32,
pub error: Color32,
pub rarity_colors: RarityColors,
}
#[derive(Serialize, Deserialize)]
pub struct RarityColors {
pub normal: Color32,
pub magic: Color32,
pub rare: Color32,
pub unique: Color32,
pub set: Color32,
pub currency: Color32,
}
impl UITheme {
pub fn apply_to_egui(&self, ctx: &egui::Context) {
let mut style = (*ctx.style()).clone();
style.visuals.window_fill = self.colors.panel;
style.visuals.panel_fill = self.colors.panel;
style.visuals.faint_bg_color = self.colors.background;
style.visuals.extreme_bg_color = self.colors.background;
style.visuals.code_bg_color = self.colors.background;
style.visuals.widgets.inactive.bg_fill = self.colors.panel;
style.visuals.widgets.hovered.bg_fill = self.colors.accent;
style.visuals.widgets.active.bg_fill = self.colors.accent;
style.visuals.selection.bg_fill = self.colors.accent;
style.visuals.selection.stroke.color = self.colors.text;
ctx.set_style(style);
}
}
This snippet shows the core structure of our UI plugin, including state management, theming, and the systems that handle different UI aspects. Think of UIState
as the brain of our UI, keeping track of everything from open windows to tooltips. UITheme
lets us define the look and feel, ensuring a consistent visual style.
HUD Implementation
Let's look at how we're building the Heads-Up Display (HUD), the info you see while playing:
// crates/hephaestus_ui/src/hud.rs
use super::*;
/// Health and resource display
pub fn render_health_mana_globes(
contexts: &mut EguiContexts,
player_stats: &PlayerStats,
ui_theme: &UITheme,
) {
let ctx = contexts.ctx_mut();
// Health Globe - Bottom Left
egui::Area::new("health_globe")
.anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(20.0, -20.0))
.show(ctx, |ui| {
ui.allocate_ui(egui::vec2(150.0, 150.0), |ui| {
// Draw globe background
let rect = ui.available_rect_before_wrap();
let painter = ui.painter();
// Background circle
painter.circle_filled(
rect.center(),
rect.width() / 2.0,
Color32::from_rgba(40, 10, 10, 200),
);
// Health fill (using custom shader would be better)
let health_percent = player_stats.health_current / player_stats.health_max;
let fill_height = rect.height() * health_percent;
painter.rect_filled(
Rect::from_min_size(
pos2(rect.left(), rect.bottom() - fill_height),
vec2(rect.width(), fill_height),
),
Rounding::none(),
Color32::from_rgb(200, 20, 20),
);
// Text overlay
ui.put(
rect,
egui::Label::new(
RichText::new(format!(
"{}/{}",
player_stats.health_current as i32,
player_stats.health_max as i32
))
.color(Color32::WHITE)
.size(16.0)
)
);
});
});
// Mana Globe - Bottom Right
egui::Area::new("mana_globe")
.anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-20.0, -20.0))
.show(ctx, |ui| {
ui.allocate_ui(egui::vec2(150.0, 150.0), |ui| {
let rect = ui.available_rect_before_wrap();
let painter = ui.painter();
painter.circle_filled(
rect.center(),
rect.width() / 2.0,
Color32::from_rgba(10, 10, 40, 200),
);
let mana_percent = player_stats.mana_current / player_stats.mana_max;
let fill_height = rect.height() * mana_percent;
painter.rect_filled(
Rect::from_min_size(
pos2(rect.left(), rect.bottom() - fill_height),
vec2(rect.width(), fill_height),
),
Rounding::none(),
Color32::from_rgb(20, 20, 200),
);
ui.put(
rect,
egui::Label::new(
RichText::new(format!(
"{}/{}",
player_stats.mana_current as i32,
player_stats.mana_max as i32
))
.color(Color32::WHITE)
.size(16.0)
)
);
});
});
}
/// Skill bar with cooldowns
pub fn render_skill_bar(
contexts: &mut EguiContexts,
skill_bar: &SkillBar,
ui_theme: &UITheme,
) {
let ctx = contexts.ctx_mut();
egui::TopBottomPanel::bottom("skill_bar")
.resizable(false)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0);
// Center the skill bar
let available_width = ui.available_width();
let skill_bar_width = skill_bar.slots.len() as f32 * 54.0;
ui.add_space((available_width - skill_bar_width) / 2.0);
for (index, slot) in skill_bar.slots.iter().enumerate() {
let response = ui.allocate_ui(egui::vec2(50.0, 50.0), |ui| {
render_skill_slot(ui, slot, index, ui_theme);
}).response;
// Handle skill activation
if response.clicked() || ui.input(|i| i.key_pressed(slot.hotkey)) {
// Trigger skill cast event
}
// Show tooltip on hover
if response.hovered() {
if let Some(skill) = &slot.skill {
show_skill_tooltip(ui, skill);
}
}
}
});
});
}
fn render_skill_slot(
ui: &mut egui::Ui,
slot: &SkillSlot,
index: usize,
theme: &UITheme,
) {
let rect = ui.available_rect_before_wrap();
let painter = ui.painter();
// Background
painter.rect_filled(
rect,
Rounding::same(4.0),
Color32::from_rgba(20, 20, 20, 200),
);
// Border
painter.rect_stroke(
rect,
Rounding::same(4.0),
Stroke::new(2.0, theme.colors.accent),
);
if let Some(skill) = &slot.skill {
// Skill icon
if let Some(texture_id) = get_skill_texture(skill.id) {
painter.image(
texture_id,
rect.shrink(4.0),
Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0)),
Color32::WHITE,
);
}
// Cooldown overlay
if !slot.cooldown_timer.finished() {
let cooldown_percent = slot.cooldown_timer.percent_left();
let overlay_height = rect.height() * cooldown_percent;
painter.rect_filled(
Rect::from_min_size(
rect.min,
vec2(rect.width(), overlay_height),
),
Rounding::same(4.0),
Color32::from_rgba(0, 0, 0, 180),
);
// Cooldown text
let remaining = slot.cooldown_timer.remaining_secs();
painter.text(
rect.center(),
Align2::CENTER_CENTER,
format!("{:.1}", remaining),
FontId::proportional(20.0),
Color32::WHITE,
);
}
// Charges indicator
if slot.max_charges > 1 {
painter.text(
rect.right_bottom() - vec2(5.0, 5.0),
Align2::RIGHT_BOTTOM,
format!("{}", slot.current_charges),
FontId::proportional(14.0),
Color32::YELLOW,
);
}
}
// Hotkey indicator
painter.text(
rect.left_top() + vec2(5.0, 5.0),
Align2::LEFT_TOP,
format!("{}", index + 1),
FontId::proportional(12.0),
Color32::GRAY,
);
}
This code handles the health and mana globes, as well as the skill bar. Notice how we use egui::Area
and egui::TopBottomPanel
to position these elements. We're also drawing shapes and text directly onto the UI, giving us fine-grained control over the visuals.
Inventory Window
The inventory is a core part of any ARPG. Here’s a glimpse of how we're building it:
// crates/hephaestus_ui/src/windows/inventory.rs
use super::*;
pub fn render_inventory_window(
contexts: &mut EguiContexts,
inventory: &mut Inventory,
ui_state: &mut UIState,
ui_theme: &UITheme,
) {
if !ui_state.open_windows.contains(&WindowType::Inventory) {
return;
}
let ctx = contexts.ctx_mut();
egui::Window::new("Inventory")
.id(egui::Id::new("inventory_window"))
.default_size(egui::vec2(400.0, 600.0))
.resizable(true)
.collapsible(false)
.show(ctx, |ui| {
// Character paper doll
ui.horizontal(|ui| {
ui.group(|ui| {
ui.set_min_size(egui::vec2(200.0, 300.0));
render_equipment_slots(ui, &mut inventory.equipment, ui_state);
});
ui.vertical(|ui| {
// Stats summary
ui.group(|ui| {
ui.label("Stats");
ui.separator();
render_stat_summary(ui, inventory);
});
// Currency display
ui.group(|ui| {
ui.label("Currency");
ui.separator();
render_currency_display(ui, &inventory.currency);
});
});
});
ui.separator();
// Inventory grid
egui::ScrollArea::vertical()
.max_height(300.0)
.show(ui, |ui| {
render_inventory_grid(ui, &mut inventory.items, ui_state, ui_theme);
});
ui.separator();
// Inventory controls
ui.horizontal(|ui| {
if ui.button("Sort").clicked() {
inventory.sort_items();
}
if ui.button("Deposit All").clicked() {
// Transfer to stash
}
ui.label(format!(
"Space: {}/{}",
inventory.used_space(),
inventory.max_space
));
});
});
}
fn render_inventory_grid(
ui: &mut egui::Ui,
items: &mut InventoryGrid,
ui_state: &mut UIState,
theme: &UITheme,
) {
let grid_size = items.size;
let cell_size = 40.0;
ui.allocate_ui(
egui::vec2(grid_size.0 as f32 * cell_size, grid_size.1 as f32 * cell_size),
|ui| {
let painter = ui.painter();
let rect = ui.available_rect_before_wrap();
// Draw grid background
for y in 0..grid_size.1 {
for x in 0..grid_size.0 {
let cell_rect = Rect::from_min_size(
rect.min + vec2(x as f32 * cell_size, y as f32 * cell_size),
vec2(cell_size, cell_size),
);
painter.rect(
cell_rect.shrink(1.0),
Rounding::same(2.0),
Color32::from_rgba(40, 40, 40, 100),
Stroke::new(1.0, Color32::from_rgba(60, 60, 60, 100)),
);
}
}
// Draw items
for item_slot in &items.items {
let item_rect = Rect::from_min_size(
rect.min + vec2(
item_slot.position.0 as f32 * cell_size,
item_slot.position.1 as f32 * cell_size,
),
vec2(
item_slot.item.size.0 as f32 * cell_size,
item_slot.item.size.1 as f32 * cell_size,
),
);
// Item background with rarity color
let rarity_color = get_rarity_color(&item_slot.item.rarity, theme);
painter.rect(
item_rect.shrink(2.0),
Rounding::same(3.0),
Color32::from_rgba(20, 20, 20, 200),
Stroke::new(2.0, rarity_color),
);
// Item icon
if let Some(texture) = get_item_texture(&item_slot.item.icon) {
painter.image(
texture,
item_rect.shrink(4.0),
Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0)),
Color32::WHITE,
);
}
// Stack count
if item_slot.item.stack_size > 1 {
painter.text(
item_rect.right_bottom() - vec2(4.0, 4.0),
Align2::RIGHT_BOTTOM,
format!("{}", item_slot.item.stack_size),
FontId::proportional(12.0),
Color32::WHITE,
);
}
// Handle interactions
let response = ui.interact(item_rect, ui.id().with(item_slot.item.id), Sense::click_and_drag());
if response.clicked_by(PointerButton::Primary) {
// Pick up item for moving
ui_state.drag_data = Some(DragData::Item(item_slot.item.clone()));
} else if response.clicked_by(PointerButton::Secondary) {
// Show context menu
show_item_context_menu(ui, &item_slot.item);
} else if response.hovered() {
// Show tooltip
ui_state.tooltip_data = Some(TooltipData::Item(item_slot.item.clone()));
}
}
// Handle drop
let response = ui.interact(rect, ui.id().with("inventory_grid"), Sense::hover());
if response.hovered() && ui.input(|i| i.pointer.any_released()) {
if let Some(DragData::Item(item)) = &ui_state.drag_data {
// Calculate grid position
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {
let relative_pos = pos - rect.min;
let grid_x = (relative_pos.x / cell_size) as u32;
let grid_y = (relative_pos.y / cell_size) as u32;
if items.can_place_item(&item, (grid_x, grid_y)) {
items.place_item(item.clone(), (grid_x, grid_y));
ui_state.drag_data = None;
}
}
}
}
}
);
}
This is where the magic happens for item management! We're using egui::Window
to create the inventory window and then laying out the different sections: character paper doll, stats, currency, and the inventory grid. The drag-and-drop functionality is handled using ui_state.drag_data
, making item moving feel natural.
Tooltip System
Tooltips are crucial for conveying information without cluttering the UI. Here's how we're implementing them:
// crates/hephaestus_ui/src/tooltips.rs
use super::*;
#[derive(Clone)]
pub enum TooltipData {
Item(Item),
Skill(Skill),
Buff(BuffEffect),
Custom(String),
}
pub fn render_tooltips(
contexts: &mut EguiContexts,
ui_state: &UIState,
theme: &UITheme,
) {
if let Some(tooltip) = &ui_state.tooltip_data {
let ctx = contexts.ctx_mut();
egui::Area::new("tooltip")
.interactable(false)
.movable(false)
.show(ctx, |ui| {
ui.set_max_width(400.0);
let frame = egui::Frame::popup(ui.style())
.fill(Color32::from_rgba(10, 10, 10, 240))
.stroke(Stroke::new(1.0, theme.colors.accent));
frame.show(ui, |ui| {
match tooltip {
TooltipData::Item(item) => render_item_tooltip(ui, item, theme),
TooltipData::Skill(skill) => render_skill_tooltip(ui, skill, theme),
TooltipData::Buff(buff) => render_buff_tooltip(ui, buff, theme),
TooltipData::Custom(text) => {
ui.label(text);
}
}
});
});
}
}
fn render_item_tooltip(ui: &mut egui::Ui, item: &Item, theme: &UITheme) {
// Item name with rarity color
let rarity_color = get_rarity_color(&item.rarity, theme);
ui.colored_label(rarity_color, &item.name);
ui.separator();
// Base type
ui.label(format!("{}", item.base_type.name));
// Requirements
if item.has_requirements() {
ui.add_space(4.0);
ui.label("Requirements:");
if item.requirements.level > 0 {
ui.label(format!(" Level: {}", item.requirements.level));
}
if item.requirements.strength > 0 {
ui.colored_label(
Color32::from_rgb(200, 100, 100),
format!(" Strength: {}", item.requirements.strength)
);
}
if item.requirements.dexterity > 0 {
ui.colored_label(
Color32::from_rgb(100, 200, 100),
format!(" Dexterity: {}", item.requirements.dexterity)
);
}
if item.requirements.intelligence > 0 {
ui.colored_label(
Color32::from_rgb(100, 100, 200),
format!(" Intelligence: {}", item.requirements.intelligence)
);
}
}
ui.separator();
// Base stats
if let Some(damage) = &item.base_stats.physical_damage {
ui.label(format!("Physical Damage: {}-{}", damage.0, damage.1));
}
if let Some(armor) = item.base_stats.armor {
ui.label(format!("Armor: {}", armor));
}
// Implicit mods
if !item.implicit_mods.is_empty() {
ui.add_space(4.0);
for mod_text in &item.implicit_mods {
ui.colored_label(Color32::from_rgb(150, 150, 200), mod_text);
}
}
ui.separator();
// Explicit mods
for mod_text in &item.explicit_mods {
ui.label(mod_text);
}
// Flavor text
if let Some(flavor) = &item.flavor_text {
ui.add_space(4.0);
ui.colored_label(Color32::from_rgb(150, 120, 80), flavor);
}
// Value
ui.add_space(4.0);
ui.separator();
ui.horizontal(|ui| {
ui.label("Value:");
render_currency_amount(ui, &item.vendor_price);
});
}
This code defines the TooltipData
enum, which can hold different types of information (item, skill, buff, etc.). The render_tooltips
function then displays the appropriate tooltip based on the data. For items, we show a detailed breakdown of stats, requirements, and flavor text.
Libraries and Rationale
Let's talk about the tools we're using and why:
Core Dependencies
- bevy_egui: An immediate mode GUI that's perfect for complex game UIs. It lets us build dynamic, data-driven interfaces.
- egui_extras: Extra widgets and layouts to make our UI even more polished.
- egui_plot: For graphs and charts in stats panels, because who doesn't love a good visual representation of data?
- image: For UI texture loading and manipulation, so we can get those icons looking crisp.
Design Decisions
- Immediate Mode: egui lets us build UIs that react instantly to changes in data. This is crucial for a dynamic ARPG.
- Theme System: Centralized styling ensures a consistent look and feel across the entire UI. Plus, it makes it easier to tweak the visuals later on.
- Drag and Drop: Native support for drag-and-drop makes inventory management a breeze.
- Resolution Independence: Our UI will scale gracefully to different screen sizes, so it looks good on any setup.
- Modular Windows: Each UI panel is independent, making it easier to manage and update individual parts of the UI.
AI Agent Assignments
We're dividing the work among different AI agents to keep things organized:
Tools Developer
- Implement core UI framework with egui integration: Laying the foundation for everything else.
- Create window management system with docking support: Letting players arrange their UI to their liking.
- Build drag and drop system for inventory: Making item management intuitive.
- Design tooltip framework with comparison logic: Giving players the info they need to make informed decisions.
- Implement UI persistence for window positions: Remembering where players left their windows.
- Create UI animation system for smooth transitions: Adding that extra layer of polish.
Graphics/Technical Artist
- Design UI visual theme matching ARPG aesthetic: Making sure our UI looks the part.
- Create UI element textures and icons: Giving the UI a visual identity.
- Implement UI particle effects for interactions: Making interactions feel satisfying.
- Build health/mana globe shaders: Creating visually appealing resource displays.
- Design damage number system with physics: Making combat feedback clear and impactful.
Gameplay Programmer
- Connect UI to game systems (inventory, skills, etc.): Making the UI functional and interactive.
- Implement UI interaction logic for complex panels: Handling the nitty-gritty of UI behavior.
- Create notification system for game events: Keeping players informed about what's happening.
- Build chat system for multiplayer: Letting players communicate.
- Design settings persistence system: Saving player preferences.
QA/Testing Automation
- Test UI responsiveness at different resolutions: Ensuring the UI works well on all screens.
- Validate drag and drop edge cases: Catching those pesky bugs.
- Test tooltip accuracy for all items: Making sure the info is correct.
- Benchmark UI performance impact: Keeping the UI snappy.
- Test keyboard navigation accessibility: Making the game accessible to all players.
Definition of Done
We'll consider this feature complete when:
- [ ] All major UI panels implemented
- [ ] Drag and drop working smoothly
- [ ] Tooltips showing accurate information
- [ ] UI scaling properly at all resolutions
- [ ] Theme system applied consistently
- [ ] Keyboard shortcuts functional
- [ ] Performance impact < 2ms per frame
- [ ] UI state persists between sessions
Related Documentation
Here are some helpful resources we're using:
Estimated Effort
- Story Points: 13 (3-4 days with AI assistance)
- Priority: P0 - Critical (Required for gameplay)
Labels
ui
, p0-critical
, size-13
, feature
, user-interface
This is gonna be awesome, guys! Let's build a UI that's as fun to use as the game is to play!