// ▄████████▄ + ███ + ▄█████████ ███ + // ███▀ ▀███ + + ███ ███▀ + ███ + + // ███ + ███ ███ ███ █████████ ███ ███ ███ ███ // ███ +███ ███ ███ ███ ███▐██████ ███ ███ ███ // ███ + ███ ███+ ███ +███ ███ + ███ ███ + ███ // ███▄ ▄███ ███▄ ███ ███ + ███ + ███ ███▄ ███ // ▀████████▀ + ▀███████ ███▄ ███▄ ▀████ ▀███████ // + + + ███ // + ▀████████████████████████████████████████████████████▀ // // This module manages the heads-up display and augmented reality overlays. use crate::prelude::*; use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}; use bevy::pbr::{NotShadowCaster, NotShadowReceiver}; use bevy::prelude::*; use bevy::scene::SceneInstance; use bevy::transform::TransformSystem; use bevy_xpbd_3d::prelude::*; use std::collections::VecDeque; use std::time::SystemTime; pub const DASHBOARD_ICON_SIZE: f32 = 64.0; pub const HUD_REFRESH_TIME: f32 = 0.1; 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 const DASHBOARD_DEF: &[(Dashboard, &str)] = &[ (Dashboard::Flashlight, "highbeams"), (Dashboard::Leak, "leak"), (Dashboard::RotationStabiliser, "rotation_stabiliser"), (Dashboard::CruiseControl, "cruise_control"), (Dashboard::Radioactivity, "radioactivity"), ]; // Player avatars: [(Avatar, model name, scale, in-game name)] pub const PLAYER_AR_AVATARS: &[(Avatar, &str, f32, &str)] = &[ (Avatar::None, "", 1.0, "No Avatar"), (Avatar::ChefHat, "suit_ar_chefhat", 1.0, "Chef Hat"), (Avatar::Wings, "suit_ar_wings", 1.0, "Wings"), (Avatar::Asteroid, "asteroid2", 1.2, "Asteroid"), ]; 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.run_if(in_control), handle_target_event, ), ); app.add_systems( PostUpdate, ( update_overlay_visibility, update_avatar.run_if(on_event::()), 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::(); app.add_event::(); } } #[derive(Event)] pub struct TargetEvent(pub Option); #[derive(Event)] pub struct UpdateOverlayVisibility; #[derive(Event)] pub struct UpdateAvatarEvent; #[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 GaugeLength(f32); #[derive(Component)] pub struct ToggleableHudElement; #[derive(Component)] pub struct ToggleableHudMapElement; #[derive(Component)] struct Selectagon; #[derive(Component)] struct PlayerAvatar; #[derive(Component)] pub struct IsTargeted; #[derive(Component)] pub struct PointOfInterestMarker(pub Entity); #[derive(Component, Debug, Copy, Clone)] pub enum Dashboard { Leak, Flashlight, RotationStabiliser, CruiseControl, Radioactivity, } #[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, pub scale: f32, } #[derive(Resource)] struct FPSUpdateTimer(Timer); pub enum Avatar { None, ChefHat, Wings, Asteroid, } pub enum LogLevel { Achievement, 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(); } } pub 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_fps = TextStyle { font: font_handle.clone(), font_size: settings.font_size_fps, color: settings.hud_color_fps, ..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 mut bundle_fps = TextBundle::from_sections([ TextSection::new("", style), // Target TextSection::new("", style_fps), // Frames per second ]) .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_centered(), 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 { 32.0 * 8.0 } else { 32.0 * 5.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() }, 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), ToggleableHudElement, )); }); // 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() }, 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() }, ToggleableHudElement, )); // 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() }, ToggleableHudElement, )); }); } // Car-Dashboard-Style icons let style_dashboard = Style { width: Val::Px(DASHBOARD_ICON_SIZE), height: Val::Px(DASHBOARD_ICON_SIZE), ..Default::default() }; commands .spawn(( NodeBundle { style: Style { width: Val::Percent(30.0), height: Val::Percent(100.0), bottom: Val::Px(40.0 + icon_size * gauges.len() as f32), left: Val::VMin(4.0), align_items: AlignItems::End, overflow: Overflow::clip(), ..default() }, visibility, ..default() }, ToggleableHudElement, )) .with_children(|builder| { for (component, filename) in DASHBOARD_DEF { builder.spawn(( *component, ImageBundle { image: UiImage::new( asset_server.load(format!("sprites/dashboard_{}.png", filename)), ), style: style_dashboard.clone(), visibility: Visibility::Hidden, ..Default::default() }, )); } }); // 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 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() }, )); load_asset("selectagon", &mut entitycmd, &*asset_server); ew_updateoverlays.send(UpdateOverlayVisibility); } fn update_dashboard( timer: ResMut, mut q_dashboard: Query<(&mut Visibility, &Dashboard)>, id2pos: Res, q_player: Query<(&actor::Suit, &Position), With>, settings: Res, ) { if !settings.hud_active || !timer.0.just_finished() { return; } let player = q_player.get_single(); if player.is_err() { return; } let (suit, pos) = player.unwrap(); for (mut vis, icon) in &mut q_dashboard { *vis = bool2vis(match icon { Dashboard::Flashlight => settings.flashlight_active, Dashboard::Leak => suit.integrity < 0.5, Dashboard::RotationStabiliser => !settings.rotation_stabilizer_active, Dashboard::CruiseControl => settings.cruise_control_active, Dashboard::Radioactivity => { if let Some(pos_jupiter) = id2pos.0.get(cmd::ID_JUPITER) { pos_jupiter.distance(pos.0) < 140_000_000.0 } else { false } } }); } } fn update_speedometer( timer: ResMut, settings: Res, jupiter_pos: Res, q_camera: Query<(&LinearVelocity, &Position), With>, q_player: Query<&actor::ExperiencesGForce, 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 !settings.hud_active || !timer.0.just_finished() { return; } if let Ok((cam_v, pos)) = q_camera.get_single() { let orbital_v = nature::orbital_velocity(pos.0 - jupiter_pos.0, nature::JUPITER_MASS); let speed = (cam_v.0 - orbital_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::inverse_lorentz_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::inverse_lorentz_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() { // G forces speed_text.sections[0].value = if let Ok(gforce) = q_player.get_single() { if gforce.gforce > 0.0001 { format!("{:.1}g\n", gforce.gforce) } else { "".to_string() } } else { "".to_string() }; // Velocity relative to target speed_text.sections[1].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() }; // "Absolute velocity", or velocity relative to orbit speed_text.sections[2].value = if speed > 0.0001 { nature::readable_speed(speed) } else { "".to_string() }; } } } fn update_gauges( timer: ResMut, q_player: Query<(&actor::HitPoints, &actor::Suit, &actor::Battery), With>, mut q_gauges: Query<(&mut Style, &mut BackgroundColor, &Gauge, &GaugeLength)>, settings: Res, ) { if !settings.hud_active || !timer.0.just_finished() { return; } let player = q_player.get_single(); if player.is_err() { return; } let (hp, suit, battery) = 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).powf(0.5), //Gauge::Integrity => suit.integrity, Gauge::Power => battery.power / battery.capacity, }; 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