// ▄████████▄ + ███ + ▄█████████ ███ + // ███▀ ▀███ + + ███ ███▀ + ███ + + // ███ + ███ ███ ███ █████████ ███ ███ ███ ███ // ███ +███ ███ ███ ███ ███▐██████ ███ ███ ███ // ███ + ███ ███+ ███ +███ ███ + ███ ███ + ███ // ███▄ ▄███ ███▄ ███ ███ + ███ + ███ ███▄ ███ // ▀████████▀ + ▀███████ ███▄ ███▄ ▀████ ▀███████ // + + + ███ // + ▀████████████████████████████████████████████████████▀ // // This module manages the heads-up display and augmented reality overlays. use crate::{actor, audio, camera, chat, nature, skeleton, var}; use bevy::pbr::{NotShadowCaster, NotShadowReceiver}; use bevy::prelude::*; use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}; use bevy::transform::TransformSystem; use bevy_xpbd_3d::prelude::*; use bevy::math::DVec3; use std::collections::VecDeque; use std::time::SystemTime; pub const HUD_REFRESH_TIME: f32 = 0.1; pub const FONT: &str = "fonts/Yupiter-Regular.ttf"; pub const LOG_MAX_TIME_S: f64 = 30.0; pub const LOG_MAX_ROWS: usize = 30; pub const LOG_MAX: usize = LOG_MAX_ROWS; pub const MAX_CHOICES: usize = 10; pub const SPEEDOMETER_WIDTH: f32 = 20.0; pub const SPEEDOMETER_HEIGHT: f32 = 10.0; pub const AMBIENT_LIGHT: f32 = 0.0; // Space is DARK pub const AMBIENT_LIGHT_AR: f32 = 20.0; //pub const REPLY_NUMBERS: [char; 10] = ['❶', '❷', '❸', '❹', '❺', '❻', '❼', '❽', '❾', '⓿']; //pub const REPLY_NUMBERS: [char; 10] = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩']; pub const REPLY_NUMBERS: [char; 10] = ['➀', '➁', '➂', '➃', '➄', '➅', '➆', '➇', '➈', '➉']; pub struct HudPlugin; impl Plugin for HudPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, setup); app.add_systems(Update, ( update_hud, update_dashboard, update_speedometer, update_gauges, handle_input, handle_target_event, )); app.add_systems(PostUpdate, ( update_overlay_visibility, update_ar_overlays .after(camera::position_to_transform) .in_set(sync::SyncSet::PositionToTransform), update_poi_overlays .after(camera::position_to_transform) .in_set(sync::SyncSet::PositionToTransform), update_target_selectagon .after(PhysicsSet::Sync) .after(camera::apply_input_to_player) .before(TransformSystem::TransformPropagate), )); app.insert_resource(AugmentedRealityState { overlays_visible: false, }); app.insert_resource(Log { logs: VecDeque::with_capacity(LOG_MAX), needs_rerendering: true, }); app.insert_resource(FPSUpdateTimer( Timer::from_seconds(HUD_REFRESH_TIME, TimerMode::Repeating))); app.add_event::(); app.add_event::(); } } #[derive(Event)] pub struct TargetEvent(pub Option); #[derive(Event)] pub struct UpdateOverlayVisibility; #[derive(Component)] struct NodeHud; #[derive(Component)] struct NodeConsole; #[derive(Component)] struct NodeChoiceText; #[derive(Component)] struct NodeSpeedometerText; #[derive(Component)] struct NodeCurrentChatLine; #[derive(Component)] struct Reticule; #[derive(Component)] struct Speedometer; #[derive(Component)] struct Speedometer2; #[derive(Component)] struct Dashboard; #[derive(Component)] struct DashboardFlashlight; #[derive(Component)] struct GaugeLength(f32); #[derive(Component)] pub struct ToggleableHudElement; #[derive(Component)] pub struct ToggleableHudMapElement; #[derive(Component)] struct Selectagon; #[derive(Component)] pub struct IsTargeted; #[derive(Component)] pub struct PointOfInterestMarker(pub Entity); #[derive(Component, Debug, Copy, Clone)] enum Gauge { Health, Power, Oxygen, Integrity, } #[derive(Resource)] pub struct AugmentedRealityState { pub overlays_visible: bool, } #[derive(Component)] pub struct AugmentedRealityOverlayBroadcaster; #[derive(Component)] pub struct AugmentedRealityOverlay { pub owner: Entity, } #[derive(Resource)] struct FPSUpdateTimer(Timer); pub enum LogLevel { Always, Warning, //Error, Info, //Debug, Chat, //Ping, } struct Message { text: String, sender: String, level: LogLevel, time: f64, } impl Message { pub fn get_freshness(&self) -> f64 { if let Ok(epoch) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { return (1.0 - (epoch.as_secs_f64() - self.time) / LOG_MAX_TIME_S).clamp(0.0, 1.0); } return 1.0; } pub fn format(&self) -> String { if self.sender.is_empty() { return self.text.clone() + "\n"; } else { return format!("{}: {}\n", self.sender, self.text); } } } #[derive(Component)] pub struct IsClickable { pub name: Option, pub pronoun: Option, pub distance: Option, } impl Default for IsClickable { fn default() -> Self { Self { name: None, pronoun: None, distance: None, }}} #[derive(Resource)] pub struct Log { logs: VecDeque, needs_rerendering: bool, } impl Log { pub fn info(&mut self, message: String) { self.add(message, "System".to_string(), LogLevel::Info); } pub fn chat(&mut self, message: String, sender: String) { self.add(message, sender, LogLevel::Chat); } pub fn warning(&mut self, message: String) { self.add(message, "WARNING".to_string(), LogLevel::Warning); } pub fn add(&mut self, text: String, sender: String, level: LogLevel) { if self.logs.len() == LOG_MAX { self.logs.pop_front(); } if let Ok(epoch) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { self.logs.push_back(Message { text, sender, level, time: epoch.as_secs_f64(), }); self.needs_rerendering = true; } } #[allow(dead_code)] pub fn remove_old(&mut self) { if let Some(message) = self.logs.front() { if let Ok(epoch) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { if epoch.as_secs_f64() - message.time > LOG_MAX_TIME_S { self.logs.pop_front(); } } } } pub fn clear(&mut self) { self.logs.clear(); } } fn setup( mut commands: Commands, settings: Res, asset_server: Res, mut ew_updateoverlays: EventWriter, ) { let visibility = if settings.hud_active { Visibility::Inherited } else { Visibility::Hidden }; let font_handle = asset_server.load(FONT); let style_conversations = TextStyle { font: font_handle.clone(), font_size: settings.font_size_conversations, color: settings.hud_color_subtitles, ..default() }; let style_console = TextStyle { font: font_handle.clone(), font_size: settings.font_size_console, color: settings.hud_color_console, ..default() }; let style_choices = TextStyle { font: font_handle.clone(), font_size: settings.font_size_choices, color: settings.hud_color_choices, ..default() }; let style_speedometer = TextStyle { font: font_handle.clone(), font_size: settings.font_size_speedometer, color: settings.hud_color_speedometer, ..default() }; let style = TextStyle { font: font_handle, font_size: settings.font_size_hud, color: settings.hud_color, ..default() }; // Add Statistics HUD let version = &settings.version; let mut bundle_fps = TextBundle::from_sections([ TextSection::new("", style.clone()), TextSection::new(format!(" OutFlyOS v{version} ☣"), style.clone()), TextSection::new("", style.clone()), TextSection::new("", style.clone()), // Speed TextSection::new("", style.clone()), // Target ]).with_style(Style { position_type: PositionType::Absolute, top: Val::VMin(2.0), left: Val::VMin(3.0), ..default() }).with_text_justify(JustifyText::Left); bundle_fps.visibility = visibility; commands.spawn(( NodeHud, ToggleableHudElement, bundle_fps, )); // Add Console // This one is intentionally NOT a ToggleableHudElement. Instead, console entries // are filtered based on whether the hud is active or not. LogLevel::Always is // even shown when hud is inactive. let bundle_chatbox = TextBundle::from_sections((0..LOG_MAX_ROWS).map(|_| TextSection::new("", style_console.clone())) ).with_style(Style { position_type: PositionType::Absolute, top: Val::VMin(0.0), right: Val::VMin(0.0), ..default() }).with_text_justify(JustifyText::Right); commands.spawn(( NodeBundle { style: Style { width: Val::Percent(50.0), align_items: AlignItems::Start, position_type: PositionType::Absolute, top: Val::VMin(2.0), right: Val::VMin(3.0), ..default() }, ..default() }, )).with_children(|parent| { parent.spawn(( bundle_chatbox, NodeConsole, )); }); // Add Reticule let reticule_handle: Handle = asset_server.load("sprites/reticule4.png"); commands.spawn(( NodeBundle { style: Style { width: Val::Percent(100.0), height: Val::Percent(100.0), align_items: AlignItems::Center, justify_content: JustifyContent::SpaceAround, ..default() }, visibility, ..default() }, ToggleableHudElement, )).with_children(|builder| { builder.spawn(( ImageBundle { image: UiImage::new(reticule_handle), style: Style { width: Val::VMin(5.0), height: Val::VMin(5.0), ..Default::default() }, ..Default::default() }, Reticule, )); }); // HP/O2/Suit Integrity/Power Gauges let gauges_handle: Handle = asset_server.load("sprites/gauge_horizontal.png"); let gauges = [ ("sprites/gauge_heart.png", Gauge::Health), ("sprites/gauge_battery.png", Gauge::Power), ("sprites/gauge_o2.png", Gauge::Oxygen), ("sprites/gauge_suit.png", Gauge::Integrity), ]; let icon_size = 24.0; let gauge_bar_padding_left = 4.0; for (i, (sprite, gauge)) in gauges.iter().enumerate() { let bar_length = if i == 0 { 196.0 } else { 128.0 }; // The bar with variable width commands.spawn(( NodeBundle { style: Style { width: Val::Percent(30.0), height: Val::Percent(100.0), bottom: Val::Px(20.0 + 24.0 * i as f32), left: Val::VMin(2.0), align_items: AlignItems::End, overflow: Overflow::clip(), ..default() }, visibility, ..default() }, Dashboard, ToggleableHudElement, )).with_children(|builder| { builder.spawn(( NodeBundle { style: Style { width: Val::Px(118.0), height: Val::Px(10.0), bottom: Val::Px(8.0), left: Val::Px(gauge_bar_padding_left + icon_size), ..Default::default() }, visibility, background_color: settings.hud_color.into(), ..Default::default() }, gauge.clone(), GaugeLength(bar_length), )); }); // The decorator sprites surrounding the bar commands.spawn(( NodeBundle { style: Style { width: Val::Percent(30.0), height: Val::Percent(100.0), bottom: Val::Px(20.0 + 24.0 * i as f32), left: Val::VMin(2.0), align_items: AlignItems::End, overflow: Overflow::clip(), ..default() }, visibility, ..default() }, Dashboard, ToggleableHudElement, )).with_children(|builder| { // The gauge symbol builder.spawn(( ImageBundle { image: UiImage::new(asset_server.load(sprite.to_string())), style: Style { width: Val::Px(icon_size), height: Val::Px(icon_size), ..Default::default() }, visibility, ..Default::default() }, )); // The gauge bar border builder.spawn(( ImageBundle { image: UiImage::new(gauges_handle.clone()), style: Style { width: Val::Px(bar_length), height: Val::Px(10.0), bottom: Val::Px(8.0), left: Val::Px(gauge_bar_padding_left), ..Default::default() }, visibility, ..Default::default() }, )); }); } // Car-Dashboard-Style icons let flashlight_visibility = bool2vis(visibility == Visibility::Visible && settings.flashlight_active); let dashboard_flashlight_handle: Handle = asset_server.load("sprites/dashboard_highbeams.png"); commands.spawn(( NodeBundle { style: Style { width: Val::Percent(30.0), height: Val::Percent(100.0), bottom: Val::VMin(SPEEDOMETER_HEIGHT + 3.0), left: Val::VMin(4.0), align_items: AlignItems::End, overflow: Overflow::clip(), ..default() }, visibility, ..default() }, Dashboard, ToggleableHudElement, )).with_children(|builder| { builder.spawn(( ImageBundle { image: UiImage::new(dashboard_flashlight_handle), style: Style { width: Val::VMin(6.0), height: Val::VMin(6.0), ..Default::default() }, visibility: flashlight_visibility, ..Default::default() }, DashboardFlashlight, )); }); // Add Speedometer let speedometer_handle: Handle = asset_server.load("sprites/speedometer.png"); commands.spawn(( NodeBundle { style: Style { width: Val::VMin(0.0), height: Val::Percent(100.0), left: Val::Vw(100.0 - SPEEDOMETER_WIDTH), align_items: AlignItems::End, overflow: Overflow::clip(), ..default() }, visibility, ..default() }, Speedometer, ToggleableHudElement, )).with_children(|builder| { builder.spawn(( ImageBundle { image: UiImage::new(speedometer_handle), style: Style { width: Val::Vw(SPEEDOMETER_WIDTH), height: Val::VMin(SPEEDOMETER_HEIGHT), ..Default::default() }, ..Default::default() }, )); }); let speedometer_handle: Handle = asset_server.load("sprites/speedometer_white.png"); commands.spawn(( NodeBundle { style: Style { width: Val::VMin(0.0), height: Val::Percent(100.0), left: Val::Vw(100.0 - SPEEDOMETER_WIDTH), align_items: AlignItems::End, overflow: Overflow::clip(), ..default() }, visibility, ..default() }, Speedometer2, ToggleableHudElement, )).with_children(|builder| { builder.spawn(( ImageBundle { image: UiImage::new(speedometer_handle), style: Style { width: Val::Vw(SPEEDOMETER_WIDTH), height: Val::VMin(SPEEDOMETER_HEIGHT), ..Default::default() }, ..Default::default() }, )); }); let mut bundle_speedometer_text = TextBundle::from_sections([ TextSection::new("", style_speedometer.clone()), // speed relative to target TextSection::new("", style_speedometer.clone()), // speed relative to orbit ]).with_style(Style { position_type: PositionType::Absolute, left: Val::Vw(100.0 - SPEEDOMETER_WIDTH + 2.0), bottom: Val::VMin(4.0), ..default() }).with_text_justify(JustifyText::Left); bundle_speedometer_text.visibility = visibility; commands.spawn(( NodeSpeedometerText, ToggleableHudElement, bundle_speedometer_text, )); // Chat "subtitles" and choices commands.spawn(NodeBundle { style: Style { width: Val::Vw(100.0), align_items: AlignItems::Center, flex_direction: FlexDirection::Column, position_type: PositionType::Absolute, bottom: Val::Vh(2.0), left: Val::Px(0.0), ..default() }, ..default() }).with_children(|builder| { builder.spawn(( TextBundle { text: Text { sections: vec![ TextSection::new("", style_conversations), ], justify: JustifyText::Center, ..default() }, style: Style { max_width: Val::Percent(50.0), margin: UiRect { bottom: Val::Vh(1.0), ..default() }, ..default() }, ..default() }, NodeCurrentChatLine, )); let choice_sections = (0..MAX_CHOICES).map(|_| TextSection::new("", style_choices.clone())); builder.spawn(( TextBundle { text: Text { sections: choice_sections.collect(), ..default() }, ..default() }, NodeChoiceText, )); }); // Selectagon let mut entitycmd = commands.spawn(( Selectagon, NotShadowCaster, NotShadowReceiver, SpatialBundle { visibility: Visibility::Hidden, ..default() }, )); skeleton::load("selectagon", &mut entitycmd, &*asset_server); ew_updateoverlays.send(UpdateOverlayVisibility); } fn update_dashboard( mut q_flashlight: Query<&mut Visibility, With>, settings: Res, ) { for mut vis in &mut q_flashlight { *vis = bool2vis(settings.flashlight_active); } } fn update_speedometer( q_camera: Query<&LinearVelocity, With>, q_target: Query<&LinearVelocity, With>, mut q_speedometer: Query<&mut Style, (With, Without)>, mut q_speedometer2: Query<&mut Style, (With, Without)>, mut q_node_speed: Query<&mut Text, With>, ) { if let Ok(cam_v) = q_camera.get_single() { let speed = cam_v.length(); let speedometer_split = 5_000.0; if let Ok(mut speedometer) = q_speedometer.get_single_mut() { let custom_c = speedometer_split; let fraction = nature::lorenz_factor_custom_c((custom_c - speed).clamp(0.0, custom_c), custom_c).clamp(0.0, 1.0) as f32; let wid = (fraction * SPEEDOMETER_WIDTH).clamp(0.0, 100.0); speedometer.width = Val::Vw(wid); } if let Ok(mut speedometer2) = q_speedometer2.get_single_mut() { let custom_c = nature::C - speedometer_split; let fraction = nature::lorenz_factor_custom_c((custom_c - speed + speedometer_split).clamp(0.0, custom_c), custom_c).clamp(0.0, 1.0) as f32; let wid = (fraction * SPEEDOMETER_WIDTH).clamp(0.0, 100.0); speedometer2.width = Val::Vw(wid); } if let Ok(mut speed_text) = q_node_speed.get_single_mut() { speed_text.sections[0].value = if let Ok(target_v) = q_target.get_single() { let delta_v = (target_v.0 - cam_v.0).length(); if delta_v > 0.0001 { format!("Δv {}\n", nature::readable_speed(delta_v)) } else { "".to_string() } } else { "".to_string() }; speed_text.sections[1].value = if speed > 0.0001 { nature::readable_speed(speed) } else { "".to_string() }; } } } fn update_gauges( q_player: Query<(&actor::HitPoints, &actor::Suit), With>, mut q_gauges: Query<(&mut Style, &mut BackgroundColor, &Gauge, &GaugeLength)>, settings: Res, ) { let player = q_player.get_single(); if player.is_err() { return; } let (hp, suit) = player.unwrap(); for (mut style, mut bg, gauge, len) in &mut q_gauges { let value: f32 = match gauge { Gauge::Health => hp.current / hp.max, Gauge::Oxygen => suit.oxygen / suit.oxygen_max, Gauge::Integrity => suit.integrity, Gauge::Power => suit.power / suit.power_max, }; if value < 0.5 { *bg = settings.hud_color_alert.into(); } else { *bg = settings.hud_color.into(); } style.width = Val::Px(len.0 * value); } } fn update_hud( diagnostics: Res, time: Res