// ▄████████▄ + ███ + ▄█████████ ███ + // ███▀ ▀███ + + ███ ███▀ + ███ + + // ███ + ███ ███ ███ █████████ ███ ███ ███ ███ // ███ +███ ███ ███ ███ ███▐██████ ███ ███ ███ // ███ + ███ ███+ ███ +███ ███ + ███ ███ + ███ // ███▄ ▄███ ███▄ ███ ███ + ███ + ███ ███▄ ███ // ▀████████▀ + ▀███████ ███▄ ███▄ ▀████ ▀███████ // + + + ███ // + ▀████████████████████████████████████████████████████▀ // // 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 serde::{Deserialize, Serialize}; 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; 4] = [0.0, 20.0, 60.0, 150.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::Battery, "battery"), (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::Hoodie, "suit_ar_hoodie", 1.0, "Hoodie"), (Avatar::HoodieUp, "suit_ar_hoodie_up", 1.0, "Hoodie Up"), (Avatar::Skirt, "suit_ar_skirt", 1.0, "Skirt"), ( Avatar::SkirtTartan, "suit_ar_skirt_tartan", 1.0, "Tartan Skirt", ), (Avatar::Dress, "suit_ar_dress", 1.0, "Dress"), (Avatar::Nekomimi, "suit_ar_nekomimi", 1.0, "Cat Ears"), (Avatar::Wings, "suit_ar_wings", 1.0, "Wings"), (Avatar::Armor, "suit_ar_armor", 1.0, "Armor"), (Avatar::Asteroid, "metis", 1.3, "Asteroid"), ]; pub const POINTERS: &[(Pointer, Option<&str>, &str)] = &[ (Pointer::None, None, "Off"), (Pointer::Tri, Some("sprites/pointer_tri.png"), "Default"), (Pointer::Dot, Some("sprites/pointer_dot.png"), "Dot"), ]; pub struct HudPlugin; impl Plugin for HudPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, setup); app.add_systems( Update, ( update_hud.run_if(game_running), update_dashboard.run_if(game_running), update_speedometer.run_if(game_running), update_gauges.run_if(game_running), handle_input.run_if(game_running).run_if(in_control), handle_target_event.run_if(game_running), ), ); app.add_systems( PostUpdate, ( update_overlay_visibility, update_avatar.run_if(on_event::<UpdateAvatarEvent>()), update_pointer.run_if(on_event::<UpdatePointerEvent>()), update_ar_overlays .run_if(game_running) .after(camera::position_to_transform) .in_set(sync::SyncSet::PositionToTransform), update_poi_overlays .run_if(game_running) .after(camera::position_to_transform) .in_set(sync::SyncSet::PositionToTransform), update_target_selectagon .run_if(game_running) .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::<TargetEvent>(); app.add_event::<UpdateAvatarEvent>(); app.add_event::<UpdatePointerEvent>(); app.add_event::<UpdateOverlayVisibility>(); } } #[derive(Event)] pub struct TargetEvent(pub Option<Entity>); #[derive(Event)] pub struct UpdateOverlayVisibility; #[derive(Event)] pub struct UpdateAvatarEvent; #[derive(Event)] pub struct UpdatePointerEvent; #[derive(Component)] struct NodeHud; #[derive(Component)] struct NodeConsole; #[derive(Component)] struct NodeChoiceText; #[derive(Component)] struct NodeSpeedometerText; #[derive(Component)] struct NodeCurrentChatLine; #[derive(Component)] struct PointerComponent(pub Pointer); #[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, Battery, } #[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); #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum Pointer { None, #[default] Tri, Dot, } pub enum Avatar { None, ChefHat, Nekomimi, Hoodie, HoodieUp, Skirt, SkirtTartan, Dress, Wings, Asteroid, Bra, Armor, } #[derive(Clone)] pub enum LogLevel { Achievement, Always, Warning, //Error, Info, //Debug, Chat, Send, //Ping, } impl LogLevel { pub fn is_subtitle(&self) -> bool { match self { LogLevel::Chat => true, LogLevel::Info => true, _ => false, } } } #[derive(Clone)] pub 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<String>, pub pronoun: Option<String>, pub distance: Option<f64>, } impl Default for IsClickable { fn default() -> Self { Self { name: None, pronoun: None, distance: None, } } } #[derive(Resource)] pub struct Log { logs: VecDeque<Message>, 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 send(&mut self, message: String) { self.add(message, "Me".to_string(), LogLevel::Send); } 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 get_subtitle_line(&self) -> Option<Message> { let messages: Vec<&Message> = self .logs .iter() .filter(|msg: &&Message| msg.level.is_subtitle()) .rev() .take(1) .collect(); if messages.len() > 0 { return Some(messages[0].clone()); } return None; } } pub fn setup( mut commands: Commands, settings: Res<Settings>, prefs: Res<Preferences>, asset_server: Res<AssetServer>, mut ew_updateoverlays: EventWriter<UpdateOverlayVisibility>, ) { 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 Pointer for (pointer_enum, sprite, _) in POINTERS { if sprite.is_none() { continue; } let sprite = sprite.unwrap(); let pointer_handle: Handle<Image> = asset_server.load(sprite.to_string()); commands .spawn(( NodeBundle { style: style_centered(), visibility, ..default() }, ToggleableHudElement, )) .with_children(|builder| { builder.spawn(( ImageBundle { image: UiImage::new(pointer_handle), style: Style { width: Val::VMin(5.0), height: Val::VMin(5.0), ..Default::default() }, visibility: bool2vis(prefs.pointer == *pointer_enum), ..Default::default() }, PointerComponent(pointer_enum.clone()), )); }); } // HP/O2/Suit Integrity/Power Gauges let gauges_handle: Handle<Image> = 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<Image> = 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<Image> = 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<FPSUpdateTimer>, mut q_dashboard: Query<(&mut Visibility, &Dashboard)>, q_player: Query<(&actor::Suit, &actor::Battery, &actor::LifeForm), With<actor::Player>>, settings: Res<Settings>, ) { if !settings.hud_active || !timer.0.just_finished() { return; } let player = q_player.get_single(); if player.is_err() { return; } let (suit, battery, lifeform) = 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::Battery => battery.overloaded_recovering, Dashboard::RotationStabiliser => !settings.rotation_stabilizer_active, Dashboard::CruiseControl => settings.cruise_control_active, Dashboard::Radioactivity => lifeform.is_radioactively_damaged, }); } } fn update_speedometer( timer: ResMut<FPSUpdateTimer>, settings: Res<Settings>, jupiter_pos: Res<game::JupiterPos>, q_camera: Query<(&LinearVelocity, &Position), With<actor::PlayerCamera>>, q_player: Query<&actor::ExperiencesGForce, With<actor::Player>>, q_target: Query<&LinearVelocity, With<IsTargeted>>, mut q_speedometer: Query<&mut Style, (With<Speedometer>, Without<Speedometer2>)>, mut q_speedometer2: Query<&mut Style, (With<Speedometer2>, Without<Speedometer>)>, mut q_node_speed: Query<&mut Text, With<NodeSpeedometerText>>, ) { 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<FPSUpdateTimer>, q_player: Query<(&actor::HitPoints, &actor::Suit, &actor::Battery), With<actor::Player>>, mut q_gauges: Query<(&mut Style, &mut BackgroundColor, &Gauge, &GaugeLength)>, settings: Res<Settings>, ) { 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<DiagnosticsStore>, time: Res<Time>, mut log: ResMut<Log>, q_camera: Query<(&Position, &LinearVelocity), With<actor::PlayerCamera>>, mut timer: ResMut<FPSUpdateTimer>, q_choices: Query<&chat::Choice>, q_chat: Query<&chat::Chat>, mut q_node_hud: Query<&mut Text, With<NodeHud>>, mut q_node_console: Query< &mut Text, (With<NodeConsole>, Without<NodeHud>, Without<NodeChoiceText>), >, mut q_node_choice: Query< &mut Text, (With<NodeChoiceText>, Without<NodeHud>, Without<NodeConsole>), >, mut q_node_currentline: Query< &mut Text, ( With<NodeCurrentChatLine>, Without<NodeHud>, Without<NodeConsole>, Without<NodeChoiceText>, ), >, settings: Res<Settings>, q_target: Query<(&IsClickable, Option<&Position>, Option<&LinearVelocity>), With<IsTargeted>>, ) { if timer.0.tick(time.delta()).just_finished() || log.needs_rerendering { let q_camera_result = q_camera.get_single(); let mut freshest_line: f64 = 0.0; if settings.hud_active && q_camera_result.is_ok() { let (pos, _) = q_camera_result.unwrap(); for mut text in &mut q_node_hud { if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) { if let Some(value) = fps.smoothed() { // Update the value of the second section text.sections[1].value = format!("{value:.0}"); } } // Target display let dist_scalar: f64; let mut target_multiple = false; let mut target_error = false; if let Ok(( IsClickable { distance: Some(dist), .. }, _, _, )) = q_target.get_single() { dist_scalar = *dist; } else { let target: Option<DVec3>; if let Ok((_, Some(targetpos), _)) = q_target.get_single() { target = Some(targetpos.0); } else if q_target.is_empty() { target = Some(DVec3::new(0.0, 0.0, 0.0)); } else if q_target.iter().len() > 1 { target_multiple = true; target = None; } else { target_error = true; target = None; } if let Some(target_pos) = target { let dist = pos.0 - target_pos; dist_scalar = dist.length(); } else { dist_scalar = 0.0; } } if target_multiple { text.sections[0].value = "ERROR: MULTIPLE TARGETS\n\n".to_string(); } else if target_error { text.sections[0].value = "ERROR: FAILED TO AQUIRE TARGET\n\n".to_string(); } else if let Ok((clickable, _, _)) = q_target.get_single() { let distance = if dist_scalar.is_nan() { "UNKNOWN".to_string() } else if dist_scalar != 0.0 { nature::readable_distance(dist_scalar) } else { "ERROR".to_string() }; let target_name = clickable.name.clone().unwrap_or("Unnamed".to_string()); let pronoun = if let Some(pronoun) = &clickable.pronoun { format!("Pronoun: {pronoun}\n") } else { "".to_string() }; text.sections[0].value = format!("Target: {target_name}\n{pronoun}Distance: {distance}\n\n"); } else { text.sections[0].value = "".to_string(); } } } let chat = q_node_console.get_single_mut(); if chat.is_err() { error!("Couldn't find HUD UI text section"); return; } let mut chat = chat.unwrap(); let choicebox = q_node_choice.get_single_mut(); if choicebox.is_err() { error!("Couldn't find HUD UI text section"); return; } let mut choicebox = choicebox.unwrap(); let node_currentline = q_node_currentline.get_single_mut(); if node_currentline.is_err() { error!("Couldn't find HUD UI text section"); return; } let mut node_currentline = node_currentline.unwrap(); let mut row = 0; // Chat Log and System Log let logfilter = if settings.hud_active { |_msg: &&Message| true } else { |msg: &&Message| match msg.level { LogLevel::Always => true, LogLevel::Warning => true, LogLevel::Achievement => true, _ => false, } }; let messages: Vec<&Message> = log .logs .iter() .filter(logfilter) .rev() .take(LOG_MAX_ROWS) .collect(); //messages.reverse(); for msg in &messages { chat.sections[row].value = msg.format(); let freshness = msg.get_freshness(); let opacity: f32 = (freshness.powf(1.5) as f32).clamp(0.0, 1.0); freshest_line = freshest_line.max(freshness); chat.sections[row].style.color = match msg.level { LogLevel::Achievement => settings.hud_color_console_achievement, LogLevel::Warning => settings.hud_color_console_warn, LogLevel::Info => settings.hud_color_console_system, LogLevel::Send => settings.hud_color_console_send, _ => settings.hud_color_console, }; chat.sections[row].style.color.set_alpha(opacity); row += 1; } // Display the last chat line as "subtitles" if !q_chat.is_empty() { if let Some(message) = log.get_subtitle_line() { node_currentline.sections[0].value = message.format(); } else { node_currentline.sections[0].value = "".to_string(); } } else { node_currentline.sections[0].value = "".to_string(); } // Blank the remaining rows while row < LOG_MAX_ROWS { chat.sections[row].value = "".to_string(); row += 1; } // Choices row = 0; let mut choices: Vec<String> = Vec::new(); let mut count = 0; for choice in &q_choices { if count > 9 { break; } let press_this = REPLY_NUMBERS[choice.key]; let reply = &choice.text; //let recipient = &choice.recipient; // TODO: indicate recipients if there's more than one choices.push(format!("{press_this} {reply}")); count += 1; } for choice in choices { if row >= MAX_CHOICES { break; } choicebox.sections[row].value = choice + "\n"; row += 1; } while row < MAX_CHOICES { choicebox.sections[row].value = if row < 4 { " \n".to_string() } else { "".to_string() }; row += 1; } log.needs_rerendering = false; //if q_choices.is_empty() && freshest_line < 0.2 { log.remove_old(); //} } } fn handle_input( keyboard_input: Res<ButtonInput<KeyCode>>, mouse_input: Res<ButtonInput<MouseButton>>, settings: Res<Settings>, mut ew_sfx: EventWriter<audio::PlaySfxEvent>, mut ew_target: EventWriter<TargetEvent>, mut ew_game: EventWriter<GameEvent>, q_objects: Query< (Entity, &Transform), ( With<IsClickable>, Without<IsTargeted>, Without<actor::PlayerDrivesThis>, Without<actor::Player>, ), >, q_camera: Query<&Transform, With<Camera>>, ) { if keyboard_input.just_pressed(settings.key_togglehud) { ew_game.send(GameEvent::SetAR(Turn::Toggle)); ew_sfx.send(audio::PlaySfxEvent(audio::Sfx::Switch)); } if settings.hud_active && mouse_input.just_pressed(settings.key_selectobject) { if let Ok(camtrans) = q_camera.get_single() { let objects: Vec<(Entity, &Transform)> = q_objects.iter().collect(); if let (Some(new_target), _dist) = camera::find_closest_target::<Entity>(objects, camtrans) { ew_target.send(TargetEvent(Some(new_target))); } else { ew_target.send(TargetEvent(None)); } } } } fn handle_target_event( mut commands: Commands, mut er_target: EventReader<TargetEvent>, mut ew_sfx: EventWriter<audio::PlaySfxEvent>, mut ew_achievement: EventWriter<game::AchievementEvent>, q_target: Query<Entity, With<IsTargeted>>, q_ids: Query<&actor::Identifier>, ) { let mut play_sfx = false; for TargetEvent(target) in er_target.read() { for old_target in &q_target { commands.entity(old_target).remove::<IsTargeted>(); play_sfx = true; } if let Some(entity) = target { // TODO: This can panic if the entity despawns in the meantime commands.entity(*entity).insert(IsTargeted); play_sfx = true; if let Ok(id) = q_ids.get(*entity) { if id.0 == cmd::ID_EARTH { ew_achievement.send(game::AchievementEvent::FindEarth); } } } if play_sfx { ew_sfx.send(audio::PlaySfxEvent(audio::Sfx::Click3)); } break; // Only accept a single event per frame } } fn update_target_selectagon( settings: Res<Settings>, mut q_selectagon: Query< (&mut Transform, &mut Visibility), (With<Selectagon>, Without<IsTargeted>, Without<Camera>), >, q_target: Query<&Transform, (With<IsTargeted>, Without<Camera>, Without<Selectagon>)>, q_camera: Query<&Transform, (With<Camera>, Without<IsTargeted>, Without<Selectagon>)>, ) { if !settings.hud_active || q_camera.is_empty() { return; } let camera_trans = q_camera.get_single().unwrap(); if let Ok((mut selectagon_trans, mut selectagon_vis)) = q_selectagon.get_single_mut() { if let Ok(target_trans) = q_target.get_single() { match *selectagon_vis { Visibility::Hidden => { *selectagon_vis = Visibility::Visible; } _ => {} } selectagon_trans.translation = target_trans.translation; selectagon_trans.scale = target_trans.scale; selectagon_trans.look_at(camera_trans.translation, camera_trans.up()); // Enlarge Selectagon to a minimum angular diameter let (angular_diameter, _, _) = camera::calc_angular_diameter(&selectagon_trans, camera_trans); let min_angular_diameter = 2.0f32.to_radians(); if angular_diameter < min_angular_diameter { selectagon_trans.scale *= min_angular_diameter / angular_diameter; } } else { match *selectagon_vis { Visibility::Hidden => {} _ => { *selectagon_vis = Visibility::Hidden; } } } } } fn update_ar_overlays( q_owners: Query< (Entity, &Transform, &Visibility), ( With<AugmentedRealityOverlayBroadcaster>, Without<AugmentedRealityOverlay>, ), >, mut q_overlays: Query<( &mut Transform, &mut Visibility, &mut AugmentedRealityOverlay, )>, settings: ResMut<Settings>, mut state: ResMut<AugmentedRealityState>, ) { let (need_activate, need_clean, need_update); if settings.hud_active { need_activate = !state.overlays_visible; need_clean = false; } else { need_activate = false; need_clean = state.overlays_visible; } need_update = settings.hud_active; state.overlays_visible = settings.hud_active; if need_update || need_clean || need_activate { for (mut trans, mut vis, ar) in &mut q_overlays { for (owner_id, owner_trans, owner_vis) in &q_owners { if owner_id == ar.owner { *trans = *owner_trans; if ar.scale != 1.0 { trans.scale *= ar.scale; } if need_clean { *vis = Visibility::Hidden; } else { *vis = *owner_vis; } break; } } } } } fn update_poi_overlays( mut q_marker: Query<(&mut Transform, &PointOfInterestMarker)>, q_parent: Query<&Transform, Without<PointOfInterestMarker>>, q_camera: Query<&Transform, (With<Camera>, Without<PointOfInterestMarker>)>, settings: ResMut<Settings>, ) { if !settings.hud_active || !settings.map_active || q_camera.is_empty() { return; } let camera_trans = q_camera.get_single().unwrap(); for (mut trans, marker) in &mut q_marker { if let Ok(parent_trans) = q_parent.get(marker.0) { // Enlarge POI marker to a minimum angular diameter trans.translation = parent_trans.translation; trans.scale = Vec3::splat(1.0); let (angular_diameter, _, _) = camera::calc_angular_diameter(&trans, camera_trans); let min_angular_diameter = 3.0f32.to_radians(); if angular_diameter < min_angular_diameter { trans.scale *= min_angular_diameter / angular_diameter; } trans.look_at(camera_trans.translation, camera_trans.up()); } } } fn update_overlay_visibility( mut q_marker: Query<&mut Visibility, With<PointOfInterestMarker>>, mut q_hudelement: Query< &mut Visibility, (With<ToggleableHudElement>, Without<PointOfInterestMarker>), >, mut q_selectagon: Query< &mut Visibility, ( With<Selectagon>, Without<ToggleableHudElement>, Without<PointOfInterestMarker>, ), >, q_target: Query< &IsTargeted, ( Without<Camera>, Without<Selectagon>, Without<PointOfInterestMarker>, Without<ToggleableHudElement>, ), >, mut ambient_light: ResMut<AmbientLight>, er_target: EventReader<UpdateOverlayVisibility>, settings: Res<Settings>, prefs: Res<Preferences>, ) { if er_target.is_empty() { return; } let check = { |check: bool| { if check { Visibility::Inherited } else { Visibility::Hidden } } }; let show_poi = check(settings.hud_active && settings.map_active); let show_hud = check(settings.hud_active); let show_selectagon = check(settings.hud_active && !q_target.is_empty()); for mut vis in &mut q_marker { *vis = show_poi; } for mut vis in &mut q_hudelement { *vis = show_hud; } for mut vis in &mut q_selectagon { *vis = show_selectagon; } ambient_light.brightness = if settings.hud_active { AMBIENT_LIGHT[prefs.light_amp] } else { AMBIENT_LIGHT[0] }; } fn update_pointer( prefs: ResMut<Preferences>, mut q_pointer: Query<(&PointerComponent, &mut Visibility)>, ) { for (pointer, mut vis) in &mut q_pointer { *vis = if pointer.0 == prefs.pointer { Visibility::Inherited } else { Visibility::Hidden } } } fn update_avatar( mut commands: Commands, mut settings: ResMut<Settings>, mut prefs: ResMut<var::Preferences>, asset_server: Res<AssetServer>, q_avatar: Query<(Entity, &SceneInstance), With<PlayerAvatar>>, q_player: Query<Entity, With<actor::Player>>, mut ew_updatemenu: EventWriter<menu::UpdateMenuEvent>, mut scene_spawner: ResMut<SceneSpawner>, ) { if settings.ar_avatar >= PLAYER_AR_AVATARS.len() { settings.ar_avatar = settings.ar_avatar % PLAYER_AR_AVATARS.len(); } prefs.avatar = settings.ar_avatar; prefs.save(); ew_updatemenu.send(menu::UpdateMenuEvent); let ava = if let Some(ava) = PLAYER_AR_AVATARS.get(settings.ar_avatar) { ava } else { error!("Avatar index out of bounds!"); return; }; let model_name = ava.1; let model_scale = ava.2; for (entity, sceneinstance) in &q_avatar { commands.entity(entity).despawn(); scene_spawner.despawn_instance(**sceneinstance); } if model_name.is_empty() { // No avatar selected. return; } if let Ok(player_entity) = q_player.get_single() { let mut entitycmd = commands.spawn(( hud::AugmentedRealityOverlay { owner: player_entity, scale: model_scale, }, world::DespawnOnPlayerDeath, PlayerAvatar, SpatialBundle { visibility: bool2vis(settings.hud_active), ..default() }, NotShadowCaster, )); load_asset(model_name, &mut entitycmd, &*asset_server); } }