use super::{ img_ids::Imgs, DEFAULT_NPC, FACTION_COLOR, GROUP_COLOR, GROUP_MEMBER, HP_COLOR, LOW_HP_COLOR, MANA_COLOR, REGION_COLOR, SAY_COLOR, TELL_COLOR, TEXT_BG, TEXT_COLOR, }; use crate::{ i18n::VoxygenLocalization, settings::GameplaySettings, ui::{fonts::ConrodVoxygenFonts, Ingameable}, }; use common::comp::{Energy, SpeechBubble, SpeechBubbleType, Stats}; use conrod_core::{ position::Align, widget::{self, Image, Rectangle, Text}, widget_ids, Color, Colorable, Positionable, Sizeable, Widget, WidgetCommon, }; const MAX_BUBBLE_WIDTH: f64 = 250.0; widget_ids! { struct Ids { // Speech bubble speech_bubble_text, speech_bubble_shadow, speech_bubble_top_left, speech_bubble_top, speech_bubble_top_right, speech_bubble_left, speech_bubble_mid, speech_bubble_right, speech_bubble_bottom_left, speech_bubble_bottom, speech_bubble_bottom_right, speech_bubble_tail, speech_bubble_icon, // Name name_bg, name, // HP level, level_skull, health_bar, health_bar_bg, health_txt, mana_bar, health_bar_fg, } } /// ui widget containing everything that goes over a character's head /// (Speech bubble, Name, Level, HP/energy bars, etc.) #[derive(WidgetCommon)] pub struct Overhead<'a> { name: &'a str, bubble: Option<&'a SpeechBubble>, stats: &'a Stats, energy: Option<&'a Energy>, own_level: u32, in_group: bool, settings: &'a GameplaySettings, pulse: f32, voxygen_i18n: &'a std::sync::Arc, imgs: &'a Imgs, fonts: &'a ConrodVoxygenFonts, #[conrod(common_builder)] common: widget::CommonBuilder, } impl<'a> Overhead<'a> { #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 pub fn new( name: &'a str, bubble: Option<&'a SpeechBubble>, stats: &'a Stats, energy: Option<&'a Energy>, own_level: u32, in_group: bool, settings: &'a GameplaySettings, pulse: f32, voxygen_i18n: &'a std::sync::Arc, imgs: &'a Imgs, fonts: &'a ConrodVoxygenFonts, ) -> Self { Self { name, bubble, stats, energy, own_level, in_group, settings, pulse, voxygen_i18n, imgs, fonts, common: widget::CommonBuilder::default(), } } } pub struct State { ids: Ids, } impl<'a> Ingameable for Overhead<'a> { fn prim_count(&self) -> usize { // Number of conrod primitives contained in the overhead display. TODO maybe // this could be done automatically? // - 2 Text::new for name // // If HP Info is shown: // - 1 for level: either Text or Image // - 3 for HP + fg + bg // - 1 for HP text // - If there's mana // - 1 Rect::new for mana // // If there's a speech bubble // - 2 Text::new for speech bubble // - 1 Image::new for icon // - 10 Image::new for speech bubble (9-slice + tail) 2 + if self.bubble.is_some() { 13 } else { 0 } + if f64::from(self.stats.health.current()) / f64::from(self.stats.health.maximum()) < 1.0 { 5 + if self.energy.is_some() { 1 } else { 0 } } else { 0 } } } impl<'a> Widget for Overhead<'a> { type Event = (); type State = State; type Style = (); fn init_state(&self, id_gen: widget::id::Generator) -> Self::State { State { ids: Ids::new(id_gen), } } #[allow(clippy::unused_unit)] // TODO: Pending review in #587 fn style(&self) -> Self::Style { () } fn update(self, args: widget::UpdateArgs) -> Self::Event { let widget::UpdateArgs { id, state, ui, .. } = args; const BARSIZE: f64 = 2.0; // Scaling const MANA_BAR_HEIGHT: f64 = BARSIZE * 1.5; const MANA_BAR_Y: f64 = MANA_BAR_HEIGHT / 2.0; // Used to set healthbar colours based on hp_percentage let hp_percentage = self.stats.health.current() as f64 / self.stats.health.maximum() as f64 * 100.0; // Compare levels to decide if a skull is shown let level_comp = self.stats.level.level() as i64 - self.own_level as i64; let health_current = (self.stats.health.current() / 10) as f64; let health_max = (self.stats.health.maximum() / 10) as f64; let name_y = if (health_current - health_max).abs() < 1e-6 { MANA_BAR_Y + 20.0 } else if level_comp > 9 && !self.in_group { MANA_BAR_Y + 38.0 } else { MANA_BAR_Y + 32.0 }; let font_size = if hp_percentage.abs() > 99.9 { 24 } else { 20 }; // Show K for numbers above 10^3 and truncate them // Show M for numbers above 10^6 and truncate them let health_cur_txt = match health_current as u32 { 0..=999 => format!("{:.0}", health_current.max(1.0)), 1000..=999999 => format!("{:.0}K", (health_current / 1000.0).max(1.0)), _ => format!("{:.0}M", (health_current as f64 / 1.0e6).max(1.0)), }; let health_max_txt = match health_max as u32 { 0..=999 => format!("{:.0}", health_max.max(1.0)), 1000..=999999 => format!("{:.0}K", (health_max / 1000.0).max(1.0)), _ => format!("{:.0}M", (health_max as f64 / 1.0e6).max(1.0)), }; // Name Text::new(&self.name) .font_id(self.fonts.cyri.conrod_id) .font_size(font_size) .color(Color::Rgba(0.0, 0.0, 0.0, 1.0)) .x_y(-1.0, name_y) .parent(id) .set(state.ids.name_bg, ui); Text::new(&self.name) .font_id(self.fonts.cyri.conrod_id) .font_size(font_size) .color(if self.in_group { GROUP_MEMBER /*} else if targets player { //TODO: Add a way to see if the entity is trying to attack the player, their pet(s) or a member of their group and recolour their nametag accordingly DEFAULT_NPC*/ } else { DEFAULT_NPC }) .x_y(0.0, name_y + 1.0) .parent(id) .set(state.ids.name, ui); // Speech bubble if let Some(bubble) = self.bubble { let dark_mode = self.settings.speech_bubble_dark_mode; let localizer = |s: &str, i| -> String { self.voxygen_i18n.get_variation(&s, i).to_string() }; let bubble_contents: String = bubble.message(localizer); let (text_color, shadow_color) = bubble_color(&bubble, dark_mode); let mut text = Text::new(&bubble_contents) .color(text_color) .font_id(self.fonts.cyri.conrod_id) .font_size(18) .up_from(state.ids.name, 26.0) .x_align_to(state.ids.name, Align::Middle) .parent(id); if let Some(w) = text.get_w(ui) { if w > MAX_BUBBLE_WIDTH { text = text.w(MAX_BUBBLE_WIDTH); } } Image::new(if dark_mode { self.imgs.dark_bubble_top_left } else { self.imgs.speech_bubble_top_left }) .w_h(16.0, 16.0) .top_left_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_top_left, ui); Image::new(if dark_mode { self.imgs.dark_bubble_top } else { self.imgs.speech_bubble_top }) .h(16.0) .padded_w_of(state.ids.speech_bubble_text, -4.0) .mid_top_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_top, ui); Image::new(if dark_mode { self.imgs.dark_bubble_top_right } else { self.imgs.speech_bubble_top_right }) .w_h(16.0, 16.0) .top_right_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_top_right, ui); Image::new(if dark_mode { self.imgs.dark_bubble_left } else { self.imgs.speech_bubble_left }) .w(16.0) .padded_h_of(state.ids.speech_bubble_text, -4.0) .mid_left_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_left, ui); Image::new(if dark_mode { self.imgs.dark_bubble_mid } else { self.imgs.speech_bubble_mid }) .padded_wh_of(state.ids.speech_bubble_text, -4.0) .top_left_with_margin_on(state.ids.speech_bubble_text, -4.0) .parent(id) .set(state.ids.speech_bubble_mid, ui); Image::new(if dark_mode { self.imgs.dark_bubble_right } else { self.imgs.speech_bubble_right }) .w(16.0) .padded_h_of(state.ids.speech_bubble_text, -4.0) .mid_right_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_right, ui); Image::new(if dark_mode { self.imgs.dark_bubble_bottom_left } else { self.imgs.speech_bubble_bottom_left }) .w_h(16.0, 16.0) .bottom_left_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_bottom_left, ui); Image::new(if dark_mode { self.imgs.dark_bubble_bottom } else { self.imgs.speech_bubble_bottom }) .h(16.0) .padded_w_of(state.ids.speech_bubble_text, -4.0) .mid_bottom_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_bottom, ui); Image::new(if dark_mode { self.imgs.dark_bubble_bottom_right } else { self.imgs.speech_bubble_bottom_right }) .w_h(16.0, 16.0) .bottom_right_with_margin_on(state.ids.speech_bubble_text, -20.0) .parent(id) .set(state.ids.speech_bubble_bottom_right, ui); let tail = Image::new(if dark_mode { self.imgs.dark_bubble_tail } else { self.imgs.speech_bubble_tail }) .parent(id) .mid_bottom_with_margin_on(state.ids.speech_bubble_text, -32.0); if dark_mode { tail.w_h(22.0, 13.0) } else { tail.w_h(22.0, 28.0) } .set(state.ids.speech_bubble_tail, ui); let mut text_shadow = Text::new(&bubble_contents) .color(shadow_color) .font_id(self.fonts.cyri.conrod_id) .font_size(18) .x_relative_to(state.ids.speech_bubble_text, 1.0) .y_relative_to(state.ids.speech_bubble_text, -1.0) .parent(id); // Move text to front (conrod depth is lowest first; not a z-index) text.depth(text_shadow.get_depth() - 1.0) .set(state.ids.speech_bubble_text, ui); if let Some(w) = text_shadow.get_w(ui) { if w > MAX_BUBBLE_WIDTH { text_shadow = text_shadow.w(MAX_BUBBLE_WIDTH); } } text_shadow.set(state.ids.speech_bubble_shadow, ui); let icon = if self.settings.speech_bubble_icon { bubble_icon(&bubble, &self.imgs) } else { self.imgs.nothing }; Image::new(icon) .w_h(16.0, 16.0) .top_left_with_margin_on(state.ids.speech_bubble_text, -16.0) // TODO: Figure out whether this should be parented. // .parent(id) .set(state.ids.speech_bubble_icon, ui); } if hp_percentage < 100.0 { // Show HP Bar let hp_percentage = self.stats.health.current() as f64 / self.stats.health.maximum() as f64 * 100.0; let hp_ani = (self.pulse * 4.0/* speed factor */).cos() * 0.5 + 1.0; //Animation timer let crit_hp_color: Color = Color::Rgba(0.79, 0.19, 0.17, hp_ani); // Background Image::new(self.imgs.enemy_health_bg) .w_h(84.0 * BARSIZE, 10.0 * BARSIZE) .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5) .color(Some(Color::Rgba(0.1, 0.1, 0.1, 0.8))) .parent(id) .set(state.ids.health_bar_bg, ui); // % HP Filling Image::new(self.imgs.enemy_bar) .w_h(73.0 * (hp_percentage / 100.0) * BARSIZE, 6.0 * BARSIZE) .x_y( (4.5 + (hp_percentage / 100.0 * 36.45 - 36.45)) * BARSIZE, MANA_BAR_Y + 7.5, ) .color(Some(if hp_percentage <= 25.0 { crit_hp_color } else if hp_percentage <= 50.0 { LOW_HP_COLOR } else { HP_COLOR })) .parent(id) .set(state.ids.health_bar, ui); let mut txt = format!("{}/{}", health_cur_txt, health_max_txt); if self.stats.is_dead { txt = self.voxygen_i18n.get("hud.group.dead").to_string() }; Text::new(&txt) .mid_top_with_margin_on(state.ids.health_bar_bg, 2.0) .font_size(10) .font_id(self.fonts.cyri.conrod_id) .color(TEXT_COLOR) .parent(id) .set(state.ids.health_txt, ui); // % Mana Filling if let Some(energy) = self.energy { let energy_factor = energy.current() as f64 / energy.maximum() as f64; Rectangle::fill_with( [72.0 * energy_factor * BARSIZE, MANA_BAR_HEIGHT], MANA_COLOR, ) .x_y( ((3.5 + (energy_factor * 36.5)) - 36.45) * BARSIZE, MANA_BAR_Y, //-32.0, ) .parent(id) .set(state.ids.mana_bar, ui); } // Foreground Image::new(self.imgs.enemy_health) .w_h(84.0 * BARSIZE, 10.0 * BARSIZE) .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5) .color(Some(Color::Rgba(1.0, 1.0, 1.0, 0.99))) .parent(id) .set(state.ids.health_bar_fg, ui); // Level const LOW: Color = Color::Rgba(0.54, 0.81, 0.94, 0.4); const HIGH: Color = Color::Rgba(1.0, 0.0, 0.0, 1.0); const EQUAL: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); // Change visuals of the level display depending on the player level/opponent // level let level_comp = self.stats.level.level() as i64 - self.own_level as i64; // + 10 level above player -> skull // + 5-10 levels above player -> high // -5 - +5 levels around player level -> equal // - 5 levels below player -> low if level_comp > 9 && !self.in_group { let skull_ani = ((self.pulse * 0.7/* speed factor */).cos() * 0.5 + 0.5) * 10.0; //Animation timer Image::new(if skull_ani as i32 == 1 && rand::random::() < 0.9 { self.imgs.skull_2 } else { self.imgs.skull }) .w_h(18.0 * BARSIZE, 18.0 * BARSIZE) .x_y(-39.0 * BARSIZE, MANA_BAR_Y + 7.0) .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0))) .parent(id) .set(state.ids.level_skull, ui); } else { let fnt_size = match self.stats.level.level() { 0..=9 => 15, 10..=99 => 12, 100..=999 => 9, _ => 2, }; Text::new(&format!("{}", self.stats.level.level())) .font_id(self.fonts.cyri.conrod_id) .font_size(fnt_size) .color(if level_comp > 4 { HIGH } else if level_comp < -5 { LOW } else { EQUAL }) .x_y(-37.0 * BARSIZE, MANA_BAR_Y + 9.0) .parent(id) .set(state.ids.level, ui); } } } } fn bubble_color(bubble: &SpeechBubble, dark_mode: bool) -> (Color, Color) { let light_color = match bubble.icon { SpeechBubbleType::Tell => TELL_COLOR, SpeechBubbleType::Say => SAY_COLOR, SpeechBubbleType::Region => REGION_COLOR, SpeechBubbleType::Group => GROUP_COLOR, SpeechBubbleType::Faction => FACTION_COLOR, SpeechBubbleType::World | SpeechBubbleType::Quest | SpeechBubbleType::Trade | SpeechBubbleType::None => TEXT_COLOR, }; if dark_mode { (light_color, TEXT_BG) } else { (TEXT_BG, light_color) } } fn bubble_icon(sb: &SpeechBubble, imgs: &Imgs) -> conrod_core::image::Id { match sb.icon { // One for each chat mode SpeechBubbleType::Tell => imgs.chat_tell_small, SpeechBubbleType::Say => imgs.chat_say_small, SpeechBubbleType::Region => imgs.chat_region_small, SpeechBubbleType::Group => imgs.chat_group_small, SpeechBubbleType::Faction => imgs.chat_faction_small, SpeechBubbleType::World => imgs.chat_world_small, SpeechBubbleType::Quest => imgs.nothing, // TODO not implemented SpeechBubbleType::Trade => imgs.nothing, // TODO not implemented SpeechBubbleType::None => imgs.nothing, // No icon (default for npcs) } }