hieroxel / src /ui.rs
EHS-Studio's picture
Claude Opus 5.5
Hieroxel: a living voxel world in Rust and Bevy
0e0990f
Raw History Blame Contribute Delete
9.2 kB
//! HUD: crosshair, hotbar, debug overlay, controls help.
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::prelude::*;
use bevy::window::{PresentMode, PrimaryWindow};
use crate::player::Player;
use crate::render::{DayCycle, VoxelMaterials};
use crate::world::block;
use crate::world::living::Living;
use crate::world::VoxelWorld;
pub struct HudPlugin;
impl Plugin for HudPlugin {
fn build(&self, app: &mut App) {
app.add_systems(PostStartup, spawn_hud)
.add_systems(Update, (update_hotbar, update_debug, toggle_panels));
}
}
#[derive(Component)]
struct HotbarSlot(usize);
#[derive(Component)]
struct HotbarIcon(usize);
#[derive(Component)]
struct SelectedName;
#[derive(Component)]
struct DebugText;
#[derive(Component)]
struct HelpText;
const HELP: &str = "Click to play | WASD move | Space jump | Shift sprint | F fly (Space/Ctrl up/down)\n\
LMB break | RMB place | MMB pick | 1-9 / wheel slot | Q/E change block\n\
T fast-forward time | +/- view distance | V vsync | G pause living world | C cave culling\n\
F3 debug | F2 screenshot | F11 fullscreen | H hide help | Esc release mouse";
fn text_font(size: f32) -> TextFont {
TextFont {
font_size: size.into(),
..default()
}
}
fn shadow() -> TextShadow {
TextShadow {
offset: Vec2::splat(1.5),
color: Color::srgba(0.0, 0.0, 0.0, 0.75),
}
}
fn spawn_hud(mut commands: Commands, mats: Res<VoxelMaterials>, player: Res<Player>) {
// Crosshair.
commands
.spawn(Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
})
.with_children(|root| {
root.spawn((
Node {
width: Val::Px(2.0),
height: Val::Px(18.0),
position_type: PositionType::Absolute,
..default()
},
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.85)),
));
root.spawn((
Node {
width: Val::Px(18.0),
height: Val::Px(2.0),
position_type: PositionType::Absolute,
..default()
},
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.85)),
));
});
// Hotbar.
commands
.spawn(Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
bottom: Val::Px(14.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(6.0),
..default()
})
.with_children(|root| {
root.spawn((
SelectedName,
Text::new(""),
text_font(16.0),
TextColor(Color::WHITE),
shadow(),
));
root.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(4.0)),
..default()
})
.insert(BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.35)))
.with_children(|bar| {
for i in 0..9 {
bar.spawn((
HotbarSlot(i),
Node {
width: Val::Px(48.0),
height: Val::Px(48.0),
border: UiRect::all(Val::Px(3.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BorderColor::all(Color::srgba(0.0, 0.0, 0.0, 0.5)),
BackgroundColor(Color::srgba(0.1, 0.1, 0.12, 0.6)),
))
.with_children(|slot| {
slot.spawn((
HotbarIcon(i),
ImageNode::new(mats.icons[player.hotbar[i] as usize].clone()),
Node {
width: Val::Px(36.0),
height: Val::Px(36.0),
..default()
},
));
});
}
});
});
commands.spawn((
DebugText,
Text::new(""),
text_font(15.0),
TextColor(Color::WHITE),
shadow(),
Node {
position_type: PositionType::Absolute,
left: Val::Px(10.0),
top: Val::Px(8.0),
..default()
},
));
commands.spawn((
HelpText,
Text::new(HELP),
text_font(13.0),
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.85)),
shadow(),
Node {
position_type: PositionType::Absolute,
right: Val::Px(10.0),
top: Val::Px(8.0),
..default()
},
));
}
fn update_hotbar(
player: Res<Player>,
mats: Res<VoxelMaterials>,
mut slots: Query<(&HotbarSlot, &mut BorderColor)>,
mut icons: Query<(&HotbarIcon, &mut ImageNode)>,
mut name: Query<&mut Text, With<SelectedName>>,
) {
if !player.is_changed() {
return;
}
for (slot, mut border) in &mut slots {
*border = if slot.0 == player.slot {
BorderColor::all(Color::srgb(1.0, 0.92, 0.55))
} else {
BorderColor::all(Color::srgba(0.0, 0.0, 0.0, 0.5))
};
}
for (icon, mut image) in &mut icons {
let handle = &mats.icons[player.hotbar[icon.0] as usize];
if image.image != *handle {
image.image = handle.clone();
}
}
if let Ok(mut text) = name.single_mut() {
text.0 = block::info(player.selected()).name.to_string();
}
}
#[allow(clippy::too_many_arguments)]
fn update_debug(
diagnostics: Res<DiagnosticsStore>,
world: Res<VoxelWorld>,
player: Res<Player>,
cycle: Res<DayCycle>,
living: Res<Living>,
windows: Query<&Window, With<PrimaryWindow>>,
mut text: Query<&mut Text, With<DebugText>>,
time: Res<Time>,
mut timer: Local<f32>,
) {
*timer -= time.delta_secs();
if *timer > 0.0 {
return;
}
*timer = 0.25;
let Ok(mut text) = text.single_mut() else { return };
let fps = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FPS)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
let ms = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FRAME_TIME)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
let vsync = windows
.single()
.map(|w| matches!(w.present_mode, PresentMode::AutoVsync | PresentMode::Fifo))
.unwrap_or(false);
let p = player.feet;
let s = world.generator.surface(p.x.floor() as i32, p.z.floor() as i32);
let facing = {
let a = (-player.yaw).rem_euclid(std::f32::consts::TAU);
["N", "NE", "E", "SE", "S", "SW", "W", "NW"][((a / std::f32::consts::FRAC_PI_4) + 0.5) as usize % 8]
};
let mesh_avg = if world.stats.mesh_jobs > 0 {
world.stats.mesh_micros as f64 / world.stats.mesh_jobs as f64 / 1000.0
} else {
0.0
};
let target = player
.target
.and_then(|(hit, _)| world.get(hit).map(|b| format!("{} at {} {} {}", block::info(b).name, hit.x, hit.y, hit.z)))
.unwrap_or_else(|| "-".into());
text.0 = format!(
"Hieroxel {fps:.0} fps ({ms:.2} ms) vsync {}\n\
XYZ {:.1} {:.1} {:.1} facing {facing} {} {}\n\
Looking at: {target}\n\
View {} chunks ({} blocks) columns {} meshed chunks {} ({} cave-culled) triangles {:.2}M\n\
Workers {} pending {} mesh avg {mesh_avg:.2} ms\n\
Time {} living world {} ({} changes)",
if vsync { "on" } else { "off" },
p.x,
p.y,
p.z,
s.biome.name(),
if player.flying { "flying" } else { "" },
world.render_distance,
world.render_distance * 32,
world.columns.len(),
world.stats.meshed_chunks,
world.culled_chunks,
world.stats.triangles as f64 / 1e6,
world.workers,
world.pending_work(),
cycle.clock(),
if living.enabled { "on" } else { "paused" },
living.growth_events,
);
}
fn toggle_panels(
keys: Res<ButtonInput<KeyCode>>,
mut debug: Query<&mut Visibility, (With<DebugText>, Without<HelpText>)>,
mut help: Query<&mut Visibility, (With<HelpText>, Without<DebugText>)>,
) {
let flip = |v: &mut Visibility| {
*v = if *v == Visibility::Hidden { Visibility::Inherited } else { Visibility::Hidden };
};
if keys.just_pressed(KeyCode::F3) {
debug.iter_mut().for_each(|mut v| flip(&mut v));
}
if keys.just_pressed(KeyCode::KeyH) {
help.iter_mut().for_each(|mut v| flip(&mut v));
}
}