Skip to main content

veloren_voxygen/hud/
overhead.rs

1use super::{
2    DEFAULT_NPC, ENEMY_HP_COLOR, FACTION_COLOR, GROUP_COLOR, GROUP_MEMBER, HP_COLOR, LOW_HP_COLOR,
3    QUALITY_EPIC, REGION_COLOR, SAY_COLOR, STAMINA_COLOR, TELL_COLOR, TEXT_BG, TEXT_COLOR,
4    cr_color, img_ids::Imgs,
5};
6use crate::{
7    GlobalState,
8    game_input::GameInput,
9    hud::{BuffIcon, controller_icons as icon_utils},
10    ui::{RichText, fonts::Fonts},
11    window::LastInput,
12};
13use common::{
14    comp::{Buffs, Energy, Health, SpeechBubble, SpeechBubbleType, Stance},
15    resources::Time,
16};
17use conrod_core::{
18    Color, Colorable, Positionable, Sizeable, Widget, WidgetCommon, color,
19    position::Align,
20    widget::{self, Image, Rectangle, RoundedRectangle, Text},
21    widget_ids,
22};
23use i18n::Localization;
24
25const MAX_BUBBLE_WIDTH: f64 = 250.0;
26widget_ids! {
27    struct Ids {
28        // Speech bubble
29        speech_bubble_text,
30        speech_bubble_shadow,
31        speech_bubble_top_left,
32        speech_bubble_top,
33        speech_bubble_top_right,
34        speech_bubble_left,
35        speech_bubble_mid,
36        speech_bubble_right,
37        speech_bubble_bottom_left,
38        speech_bubble_bottom,
39        speech_bubble_bottom_right,
40        speech_bubble_tail,
41        speech_bubble_icon,
42
43        // Name
44        name_bg,
45        name,
46
47        // HP
48        level,
49        level_skull,
50        hardcore,
51        health_bar,
52        decay_bar,
53        health_bar_bg,
54        health_txt,
55        mana_bar,
56        health_bar_fg,
57
58        // Buffs
59        buffs_align,
60        buffs[],
61        buff_timers[],
62
63        // Interaction hints
64        interaction_hints_action,
65        interaction_hints_input,
66        interaction_hints_bg,
67    }
68}
69
70pub struct Info<'a> {
71    pub name: Option<String>,
72    pub health: Option<&'a Health>,
73    pub buffs: Option<&'a Buffs>,
74    pub energy: Option<&'a Energy>,
75    pub combat_rating: Option<f32>,
76    pub hardcore: bool,
77    pub stance: Option<&'a Stance>,
78}
79
80/// Determines whether to show the healthbar
81pub fn should_show_healthbar(health: &Health) -> bool {
82    (health.current() - health.maximum()).abs() > Health::HEALTH_EPSILON
83        || health.current() < health.base_max()
84}
85/// Determines if there is decayed health being applied
86pub fn decayed_health_displayed(health: &Health) -> bool {
87    (1.0 - health.maximum() / health.base_max()) > 0.0
88}
89/// ui widget containing everything that goes over a character's head
90/// (Speech bubble, Name, Level, HP/energy bars, etc.)
91#[derive(WidgetCommon)]
92pub struct Overhead<'a> {
93    info: Option<Info<'a>>,
94    bubble: Option<&'a SpeechBubble>,
95    in_group: bool,
96    pulse: f32,
97    interaction_options: Vec<(GameInput, String)>,
98
99    i18n: &'a Localization,
100    imgs: &'a Imgs,
101    fonts: &'a Fonts,
102    time: &'a Time,
103    global_state: &'a GlobalState,
104
105    #[conrod(common_builder)]
106    common: widget::CommonBuilder,
107}
108
109impl<'a> Overhead<'a> {
110    pub fn new(
111        info: Option<Info<'a>>,
112        bubble: Option<&'a SpeechBubble>,
113        in_group: bool,
114        pulse: f32,
115        interaction_options: Vec<(GameInput, String)>,
116        i18n: &'a Localization,
117        imgs: &'a Imgs,
118        fonts: &'a Fonts,
119        time: &'a Time,
120        global_state: &'a GlobalState,
121    ) -> Self {
122        Self {
123            info,
124            bubble,
125            in_group,
126            pulse,
127            interaction_options,
128            i18n,
129            imgs,
130            fonts,
131            time,
132            global_state,
133            common: widget::CommonBuilder::default(),
134        }
135    }
136}
137
138pub struct State {
139    ids: Ids,
140}
141
142impl Widget for Overhead<'_> {
143    type Event = ();
144    type State = State;
145    type Style = ();
146
147    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
148        State {
149            ids: Ids::new(id_gen),
150        }
151    }
152
153    fn style(&self) -> Self::Style {}
154
155    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
156        let widget::UpdateArgs { id, state, ui, .. } = args;
157        const BARSIZE: f64 = 2.0; // Scaling
158        const MANA_BAR_HEIGHT: f64 = BARSIZE * 1.5;
159        const MANA_BAR_Y: f64 = MANA_BAR_HEIGHT / 2.0;
160        if let Some(Info {
161            ref name,
162            health,
163            buffs,
164            energy,
165            combat_rating,
166            hardcore,
167            stance,
168        }) = self.info
169        {
170            // Used to set healthbar colours based on hp_percentage
171            let hp_percentage = health.map_or(100.0, |h| {
172                f64::from(h.current() / h.base_max().max(h.maximum()) * 100.0)
173            });
174            // Compare levels to decide if a skull is shown
175            let health_current = health.map_or(1.0, |h| f64::from(h.current()));
176            let health_max = health.map_or(1.0, |h| f64::from(h.maximum()));
177            let name_y = if (health_current - health_max).abs() < 1e-6 {
178                MANA_BAR_Y + 20.0
179            } else {
180                MANA_BAR_Y + 32.0
181            };
182            let font_size = if hp_percentage.abs() > 99.9 { 24 } else { 20 };
183            // Show K for numbers above 10^3 and truncate them
184            // Show M for numbers above 10^6 and truncate them
185            let health_cur_txt = if self.global_state.settings.interface.use_health_prefixes {
186                match health_current as u32 {
187                    0..=999 => format!("{:.0}", health_current.max(1.0)),
188                    1000..=999999 => format!("{:.0}K", (health_current / 1000.0).max(1.0)),
189                    _ => format!("{:.0}M", (health_current / 1.0e6).max(1.0)),
190                }
191            } else {
192                format!("{:.0}", health_current.max(1.0))
193            };
194            let health_max_txt = if self.global_state.settings.interface.use_health_prefixes {
195                match health_max as u32 {
196                    0..=999 => format!("{:.0}", health_max.max(1.0)),
197                    1000..=999999 => format!("{:.0}K", (health_max / 1000.0).max(1.0)),
198                    _ => format!("{:.0}M", (health_max / 1.0e6).max(1.0)),
199                }
200            } else {
201                format!("{:.0}", health_max.max(1.0))
202            };
203            // Buffs
204            // Alignment
205            let buff_icons = buffs
206                .as_ref()
207                .map(|buffs| BuffIcon::icons_vec(buffs, stance))
208                .unwrap_or_default();
209            let buff_count = buff_icons.len().min(11);
210            Rectangle::fill_with([168.0, 100.0], color::TRANSPARENT)
211                .x_y(-1.0, name_y + 60.0)
212                .parent(id)
213                .set(state.ids.buffs_align, ui);
214
215            let generator = &mut ui.widget_id_generator();
216            if state.ids.buffs.len() < buff_count {
217                state.update(|state| state.ids.buffs.resize(buff_count, generator));
218            };
219            if state.ids.buff_timers.len() < buff_count {
220                state.update(|state| state.ids.buff_timers.resize(buff_count, generator));
221            };
222
223            let buff_ani = ((self.pulse * 4.0).cos() * 0.5 + 0.8) + 0.5; //Animation timer
224            let pulsating_col = Color::Rgba(1.0, 1.0, 1.0, buff_ani);
225            let norm_col = Color::Rgba(1.0, 1.0, 1.0, 1.0);
226            // Create Buff Widgets
227            if self.bubble.is_none() {
228                state
229                    .ids
230                    .buffs
231                    .iter()
232                    .copied()
233                    .zip(state.ids.buff_timers.iter().copied())
234                    .zip(buff_icons.iter())
235                    .enumerate()
236                    .for_each(|(i, ((id, timer_id), buff))| {
237                        // Limit displayed buffs
238                        let max_duration = buff.kind.max_duration();
239                        let current_duration = buff.end_time.map(|end| end - self.time.0);
240                        let duration_percentage = current_duration.map_or(1000.0, |cur| {
241                            max_duration.map_or(1000.0, |max| cur / max.0 * 1000.0)
242                        }) as u32; // Percentage to determine which frame of the timer overlay is displayed
243                        let buff_img = buff.kind.image(self.imgs);
244                        let buff_widget = Image::new(buff_img).w_h(20.0, 20.0);
245                        // Sort buffs into rows of 5 slots
246                        let x = i % 5;
247                        let y = i / 5;
248                        let buff_widget = buff_widget.bottom_left_with_margins_on(
249                            state.ids.buffs_align,
250                            0.0 + y as f64 * (21.0),
251                            0.0 + x as f64 * (21.0),
252                        );
253                        buff_widget
254                            .color(if current_duration.is_some_and(|cur| cur < 10.0) {
255                                Some(pulsating_col)
256                            } else {
257                                Some(norm_col)
258                            })
259                            .set(id, ui);
260
261                        Image::new(match duration_percentage as u64 {
262                            875..=1000 => self.imgs.nothing, // 8/8
263                            750..=874 => self.imgs.buff_0,   // 7/8
264                            625..=749 => self.imgs.buff_1,   // 6/8
265                            500..=624 => self.imgs.buff_2,   // 5/8
266                            375..=499 => self.imgs.buff_3,   // 4/8
267                            250..=374 => self.imgs.buff_4,   // 3/8
268                            125..=249 => self.imgs.buff_5,   // 2/8
269                            0..=124 => self.imgs.buff_6,     // 1/8
270                            _ => self.imgs.nothing,
271                        })
272                        .w_h(20.0, 20.0)
273                        .middle_of(id)
274                        .set(timer_id, ui);
275                    });
276            }
277            // Name
278            Text::new(name.as_deref().unwrap_or(""))
279                //Text::new(&format!("{} [{:?}]", name, combat_rating)) // <- Uncomment to debug combat ratings
280                .font_id(self.fonts.cyri.conrod_id)
281                .font_size(font_size)
282                .color(Color::Rgba(0.0, 0.0, 0.0, 1.0))
283                .x_y(-1.0, name_y)
284                .parent(id)
285                .set(state.ids.name_bg, ui);
286            Text::new(name.as_deref().unwrap_or(""))
287                //Text::new(&format!("{} [{:?}]", name, combat_rating)) // <- Uncomment to debug combat ratings
288                .font_id(self.fonts.cyri.conrod_id)
289                .font_size(font_size)
290                .color(if self.in_group {
291                    GROUP_MEMBER
292                /*} 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
293                DEFAULT_NPC*/
294                } else {
295                    DEFAULT_NPC
296                })
297                .x_y(0.0, name_y + 1.0)
298                .parent(id)
299                .set(state.ids.name, ui);
300
301            match health {
302                Some(health)
303                    if should_show_healthbar(health) || decayed_health_displayed(health) =>
304                {
305                    // Show HP Bar
306                    let hp_ani = (self.pulse * 4.0/* speed factor */).cos() * 0.5 + 1.0; //Animation timer
307                    let crit_hp_color: Color = Color::Rgba(0.93, 0.59, 0.03, hp_ani);
308                    let decayed_health = f64::from(1.0 - health.maximum() / health.base_max());
309                    // Background
310                    Image::new(if self.in_group {self.imgs.health_bar_group_bg} else {self.imgs.enemy_health_bg})
311                        .w_h(84.0 * BARSIZE, 10.0 * BARSIZE)
312                        .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5)
313                        .color(Some(Color::Rgba(0.1, 0.1, 0.1, 0.8)))
314                        .parent(id)
315                        .set(state.ids.health_bar_bg, ui);
316
317                    // % HP Filling
318                    let size_factor = (hp_percentage / 100.0) * BARSIZE;
319                    let w = if self.in_group {
320                        82.0 * size_factor
321                    } else {
322                        73.0 * size_factor
323                    };
324                    let h = 6.0 * BARSIZE;
325                    let x = if self.in_group {
326                        (0.0 + (hp_percentage / 100.0 * 41.0 - 41.0)) * BARSIZE
327                    } else {
328                        (4.5 + (hp_percentage / 100.0 * 36.45 - 36.45)) * BARSIZE
329                    };
330                    Image::new(self.imgs.enemy_bar)
331                        .w_h(w, h)
332                        .x_y(x, MANA_BAR_Y + 8.0)
333                        .color(if self.in_group {
334                            // Different HP bar colors only for group members
335                            Some(match hp_percentage {
336                                x if (0.0..25.0).contains(&x) => crit_hp_color,
337                                x if (25.0..50.0).contains(&x) => LOW_HP_COLOR,
338                                _ => HP_COLOR,
339                            })
340                        } else {
341                            Some(ENEMY_HP_COLOR)
342                        })
343                        .parent(id)
344                        .set(state.ids.health_bar, ui);
345
346                    if decayed_health > 0.0 {
347                        let x_decayed = if self.in_group {
348                            (0.0 - (decayed_health * 41.0 - 41.0)) * BARSIZE
349                        } else {
350                            (4.5 - (decayed_health * 36.45 - 36.45)) * BARSIZE
351                        };
352
353                        let decay_bar_len = decayed_health
354                            * if self.in_group {
355                                82.0 * BARSIZE
356                            } else {
357                                73.0 * BARSIZE
358                            };
359                        Image::new(self.imgs.enemy_bar)
360                            .w_h(decay_bar_len, h)
361                            .x_y(x_decayed, MANA_BAR_Y + 8.0)
362                            .color(Some(QUALITY_EPIC))
363                            .parent(id)
364                            .set(state.ids.decay_bar, ui);
365                    }
366                    let mut txt = format!("{}/{}", health_cur_txt, health_max_txt);
367                    if health.is_dead {
368                        txt = self.i18n.get_msg("hud-group-dead").to_string()
369                    };
370                    Text::new(&txt)
371                        .mid_top_with_margin_on(state.ids.health_bar_bg, 2.0)
372                        .font_size(10)
373                        .font_id(self.fonts.cyri.conrod_id)
374                        .color(TEXT_COLOR)
375                        .parent(id)
376                        .set(state.ids.health_txt, ui);
377
378                    // % Mana Filling
379                    if let Some(energy) = energy {
380                        let energy_factor = f64::from(energy.current() / energy.maximum());
381                        let size_factor = energy_factor * BARSIZE;
382                        let w = if self.in_group {
383                            80.0 * size_factor
384                        } else {
385                            72.0 * size_factor
386                        };
387                        let x = if self.in_group {
388                            ((0.0 + (energy_factor * 40.0)) - 40.0) * BARSIZE
389                        } else {
390                            ((3.5 + (energy_factor * 36.5)) - 36.45) * BARSIZE
391                        };
392                        Rectangle::fill_with([w, MANA_BAR_HEIGHT], STAMINA_COLOR)
393                            .x_y(
394                                x, MANA_BAR_Y, //-32.0,
395                            )
396                            .parent(id)
397                            .set(state.ids.mana_bar, ui);
398                    }
399
400                    // Foreground
401                    Image::new(if self.in_group {self.imgs.health_bar_group} else {self.imgs.enemy_health})
402                .w_h(84.0 * BARSIZE, 10.0 * BARSIZE)
403                .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5)
404                .color(Some(Color::Rgba(1.0, 1.0, 1.0, 0.99)))
405                .parent(id)
406                .set(state.ids.health_bar_fg, ui);
407
408                    if let Some(combat_rating) = combat_rating {
409                        let indicator_col = cr_color(combat_rating);
410                        let artifact_diffculty = 122.0;
411
412                        if combat_rating > artifact_diffculty && !self.in_group {
413                            let skull_ani =
414                                ((self.pulse * 0.7/* speed factor */).cos() * 0.5 + 0.5) * 10.0; //Animation timer
415                            Image::new(if skull_ani as i32 == 1 && rand::random::<f32>() < 0.9 {
416                                self.imgs.skull_2
417                            } else {
418                                self.imgs.skull
419                            })
420                            .w_h(18.0 * BARSIZE, 18.0 * BARSIZE)
421                            .x_y(-39.0 * BARSIZE, MANA_BAR_Y + 7.0)
422                            .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0)))
423                            .parent(id)
424                            .set(state.ids.level_skull, ui);
425                        } else {
426                            Image::new(if self.in_group {
427                                self.imgs.nothing
428                            } else {
429                                self.imgs.combat_rating_ico
430                            })
431                            .w_h(7.0 * BARSIZE, 7.0 * BARSIZE)
432                            .x_y(-37.0 * BARSIZE, MANA_BAR_Y + 6.0)
433                            .color(Some(indicator_col))
434                            .parent(id)
435                            .set(state.ids.level, ui);
436                        }
437                    }
438
439                    if hardcore {
440                        Image::new(self.imgs.hardcore)
441                            .w_h(18.0 * BARSIZE, 18.0 * BARSIZE)
442                            .x_y(39.0 * BARSIZE, MANA_BAR_Y + 13.0)
443                            .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0)))
444                            .parent(id)
445                            .set(state.ids.hardcore, ui);
446                    }
447                },
448                _ => {},
449            }
450
451            // Interaction hints
452            if !self.interaction_options.is_empty() {
453                let scale = 30.0;
454                let btn_rect_size = scale * 0.8;
455                let btn_font_size = scale * 0.6;
456                let btn_radius = btn_rect_size / 5.0;
457                let btn_color = Color::Rgba(0.0, 0.0, 0.0, 0.8);
458                let mut max_w = btn_rect_size;
459                let spacing = 8.0;
460
461                // get the inputs and actions separately
462                let interactions: Vec<(String, String)> = match self
463                    .global_state
464                    .window
465                    .last_input()
466                {
467                    LastInput::KeyboardMouse => self
468                        .interaction_options
469                        .iter()
470                        .map(|(input, action)| {
471                            match self.global_state.settings.controls.get_binding(*input) {
472                                Some(binding) => (binding.display_string(), action.to_string()),
473                                None => (icon_utils::UNBOUND_KEY.to_string(), action.to_string()),
474                            }
475                        })
476                        .collect(),
477
478                    LastInput::Controller => self
479                        .interaction_options
480                        .iter()
481                        .map(|(input, action)| {
482                            let input_str = icon_utils::get_controller_input_string(
483                                *input,
484                                &self.global_state.settings,
485                                self.global_state.window.controller_type(),
486                            );
487
488                            match input_str {
489                                Some(binding) => (binding, action.to_string()),
490                                None => (icon_utils::UNBOUND_KEY.to_string(), action.to_string()),
491                            }
492                        })
493                        .collect(),
494                };
495
496                // create two strings for inputs and actions. Actions should be left aligned
497                // with each other, and should not be influenced by multi-input input strings
498                // icons string
499                let mut temp_list: Vec<String> = Vec::new();
500                for i in &interactions {
501                    // take the string element
502                    let s = i.0.clone();
503                    temp_list.push(s);
504                }
505                let icons_input = temp_list.join("\n");
506
507                // actions string
508                let mut temp_list: Vec<String> = Vec::new();
509                for i in &interactions {
510                    let s = i.1.clone();
511                    temp_list.push(s);
512                }
513                let action_input = temp_list.join("\n");
514
515                let anchor_id = self.info.map_or(state.ids.name, |info| {
516                    if info.health.is_some_and(should_show_healthbar) {
517                        if info.energy.is_some() {
518                            state.ids.mana_bar
519                        } else {
520                            state.ids.health_bar
521                        }
522                    } else {
523                        state.ids.name
524                    }
525                });
526
527                // render the actions/text first (left aligned)
528                let actions_hint = RichText::new(&action_input, self.imgs)
529                    .font_id(self.fonts.cyri.conrod_id)
530                    .font_size(btn_font_size as u32)
531                    .color(TEXT_COLOR)
532                    .parent(id)
533                    .justify(conrod_core::text::Justify::Left);
534
535                let [actions_w, actions_h] = actions_hint.get_wh(ui).unwrap_or([btn_rect_size; 2]);
536                max_w += actions_w;
537                let max_h = actions_h;
538
539                // render inputs left of actions (right aligned)
540                let inputs_hint = RichText::new(&icons_input, self.imgs)
541                    .font_id(self.fonts.cyri.conrod_id)
542                    .font_size(btn_font_size as u32)
543                    .color(TEXT_COLOR)
544                    .parent(id)
545                    .justify(conrod_core::text::Justify::Right);
546
547                let [inputs_w, _inputs_h] = inputs_hint.get_wh(ui).unwrap_or([btn_rect_size; 2]);
548                max_w += inputs_w;
549                let box_offset = -(inputs_w + spacing) / 2.0;
550
551                // shift actions/text to the right based on the width of inputs
552                // this should help the input+actions output look centered
553                let centering_offset = (inputs_w + spacing) / 2.0;
554
555                actions_hint
556                    .down_from(anchor_id, 12.0)
557                    .x_relative_to(anchor_id, centering_offset)
558                    .depth(1.0)
559                    .set(state.ids.interaction_hints_action, ui);
560
561                inputs_hint
562                    .left_from(state.ids.interaction_hints_action, spacing)
563                    .depth(1.0)
564                    .set(state.ids.interaction_hints_input, ui);
565
566                RoundedRectangle::fill_with(
567                    [max_w + btn_radius * 2.0, max_h + btn_radius * 2.0],
568                    btn_radius,
569                    btn_color,
570                )
571                .depth(2.0)
572                .x_relative_to(state.ids.interaction_hints_action, box_offset)
573                .align_middle_y_of(state.ids.interaction_hints_action)
574                .parent(id)
575                .set(state.ids.interaction_hints_bg, ui);
576            }
577        }
578        // Speech bubble
579        if let Some(bubble) = self.bubble {
580            let dark_mode = self.global_state.settings.interface.speech_bubble_dark_mode;
581            let bubble_contents: String = self.i18n.get_content(bubble.content());
582            let (text_color, shadow_color) = bubble_color(bubble, dark_mode);
583            let mut text = Text::new(&bubble_contents)
584                .color(text_color)
585                .font_id(self.fonts.cyri.conrod_id)
586                .font_size(18)
587                .up_from(state.ids.name, 26.0)
588                .x_align_to(state.ids.name, Align::Middle)
589                .parent(id);
590
591            if let Some(w) = text.get_w(ui)
592                && w > MAX_BUBBLE_WIDTH
593            {
594                text = text.w(MAX_BUBBLE_WIDTH);
595            }
596            Image::new(if dark_mode {
597                self.imgs.dark_bubble_top_left
598            } else {
599                self.imgs.speech_bubble_top_left
600            })
601            .w_h(16.0, 16.0)
602            .top_left_with_margin_on(state.ids.speech_bubble_text, -20.0)
603            .parent(id)
604            .set(state.ids.speech_bubble_top_left, ui);
605            Image::new(if dark_mode {
606                self.imgs.dark_bubble_top
607            } else {
608                self.imgs.speech_bubble_top
609            })
610            .h(16.0)
611            .padded_w_of(state.ids.speech_bubble_text, -4.0)
612            .mid_top_with_margin_on(state.ids.speech_bubble_text, -20.0)
613            .parent(id)
614            .set(state.ids.speech_bubble_top, ui);
615            Image::new(if dark_mode {
616                self.imgs.dark_bubble_top_right
617            } else {
618                self.imgs.speech_bubble_top_right
619            })
620            .w_h(16.0, 16.0)
621            .top_right_with_margin_on(state.ids.speech_bubble_text, -20.0)
622            .parent(id)
623            .set(state.ids.speech_bubble_top_right, ui);
624            Image::new(if dark_mode {
625                self.imgs.dark_bubble_left
626            } else {
627                self.imgs.speech_bubble_left
628            })
629            .w(16.0)
630            .padded_h_of(state.ids.speech_bubble_text, -4.0)
631            .mid_left_with_margin_on(state.ids.speech_bubble_text, -20.0)
632            .parent(id)
633            .set(state.ids.speech_bubble_left, ui);
634            Image::new(if dark_mode {
635                self.imgs.dark_bubble_mid
636            } else {
637                self.imgs.speech_bubble_mid
638            })
639            .padded_wh_of(state.ids.speech_bubble_text, -4.0)
640            .top_left_with_margin_on(state.ids.speech_bubble_text, -4.0)
641            .parent(id)
642            .set(state.ids.speech_bubble_mid, ui);
643            Image::new(if dark_mode {
644                self.imgs.dark_bubble_right
645            } else {
646                self.imgs.speech_bubble_right
647            })
648            .w(16.0)
649            .padded_h_of(state.ids.speech_bubble_text, -4.0)
650            .mid_right_with_margin_on(state.ids.speech_bubble_text, -20.0)
651            .parent(id)
652            .set(state.ids.speech_bubble_right, ui);
653            Image::new(if dark_mode {
654                self.imgs.dark_bubble_bottom_left
655            } else {
656                self.imgs.speech_bubble_bottom_left
657            })
658            .w_h(16.0, 16.0)
659            .bottom_left_with_margin_on(state.ids.speech_bubble_text, -20.0)
660            .parent(id)
661            .set(state.ids.speech_bubble_bottom_left, ui);
662            Image::new(if dark_mode {
663                self.imgs.dark_bubble_bottom
664            } else {
665                self.imgs.speech_bubble_bottom
666            })
667            .h(16.0)
668            .padded_w_of(state.ids.speech_bubble_text, -4.0)
669            .mid_bottom_with_margin_on(state.ids.speech_bubble_text, -20.0)
670            .parent(id)
671            .set(state.ids.speech_bubble_bottom, ui);
672            Image::new(if dark_mode {
673                self.imgs.dark_bubble_bottom_right
674            } else {
675                self.imgs.speech_bubble_bottom_right
676            })
677            .w_h(16.0, 16.0)
678            .bottom_right_with_margin_on(state.ids.speech_bubble_text, -20.0)
679            .parent(id)
680            .set(state.ids.speech_bubble_bottom_right, ui);
681            let tail = Image::new(if dark_mode {
682                self.imgs.dark_bubble_tail
683            } else {
684                self.imgs.speech_bubble_tail
685            })
686            .parent(id)
687            .mid_bottom_with_margin_on(state.ids.speech_bubble_text, -32.0);
688
689            if dark_mode {
690                tail.w_h(22.0, 13.0)
691            } else {
692                tail.w_h(22.0, 28.0)
693            }
694            .set(state.ids.speech_bubble_tail, ui);
695
696            let mut text_shadow = Text::new(&bubble_contents)
697                .color(shadow_color)
698                .font_id(self.fonts.cyri.conrod_id)
699                .font_size(18)
700                .x_relative_to(state.ids.speech_bubble_text, 1.0)
701                .y_relative_to(state.ids.speech_bubble_text, -1.0)
702                .parent(id);
703            // Move text to front (conrod depth is lowest first; not a z-index)
704            text.depth(text_shadow.get_depth() - 1.0)
705                .set(state.ids.speech_bubble_text, ui);
706            if let Some(w) = text_shadow.get_w(ui)
707                && w > MAX_BUBBLE_WIDTH
708            {
709                text_shadow = text_shadow.w(MAX_BUBBLE_WIDTH);
710            }
711            text_shadow.set(state.ids.speech_bubble_shadow, ui);
712            let icon = if self.global_state.settings.interface.speech_bubble_icon {
713                bubble_icon(bubble, self.imgs)
714            } else {
715                self.imgs.nothing
716            };
717            Image::new(icon)
718                    .w_h(16.0, 16.0)
719                    .top_left_with_margin_on(state.ids.speech_bubble_text, -16.0)
720                    // TODO: Figure out whether this should be parented.
721                    // .parent(id)
722                    .set(state.ids.speech_bubble_icon, ui);
723        }
724    }
725}
726
727fn bubble_color(bubble: &SpeechBubble, dark_mode: bool) -> (Color, Color) {
728    let light_color = match bubble.icon {
729        SpeechBubbleType::Tell => TELL_COLOR,
730        SpeechBubbleType::Say => SAY_COLOR,
731        SpeechBubbleType::Region => REGION_COLOR,
732        SpeechBubbleType::Group => GROUP_COLOR,
733        SpeechBubbleType::Faction => FACTION_COLOR,
734        SpeechBubbleType::World
735        | SpeechBubbleType::Quest
736        | SpeechBubbleType::Trade
737        | SpeechBubbleType::None => TEXT_COLOR,
738    };
739    if dark_mode {
740        (light_color, TEXT_BG)
741    } else {
742        (TEXT_BG, light_color)
743    }
744}
745
746fn bubble_icon(sb: &SpeechBubble, imgs: &Imgs) -> conrod_core::image::Id {
747    match sb.icon {
748        // One for each chat mode
749        SpeechBubbleType::Tell => imgs.chat_tell_small,
750        SpeechBubbleType::Say => imgs.chat_say_small,
751        SpeechBubbleType::Region => imgs.chat_region_small,
752        SpeechBubbleType::Group => imgs.chat_group_small,
753        SpeechBubbleType::Faction => imgs.chat_faction_small,
754        SpeechBubbleType::World => imgs.chat_world_small,
755        SpeechBubbleType::Quest => imgs.nothing, // TODO not implemented
756        SpeechBubbleType::Trade => imgs.nothing, // TODO not implemented
757        SpeechBubbleType::None => imgs.nothing,  // No icon (default for npcs)
758    }
759}