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, IconHandler, controller_icons::LayerIconIds},
10    ui::{Ingameable, 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,
65        interaction_hints_bg,
66        btns[], // interaction options
67        icns[], // controller icons
68    }
69}
70
71pub struct Info<'a> {
72    pub name: Option<String>,
73    pub health: Option<&'a Health>,
74    pub buffs: Option<&'a Buffs>,
75    pub energy: Option<&'a Energy>,
76    pub combat_rating: Option<f32>,
77    pub hardcore: bool,
78    pub stance: Option<&'a Stance>,
79}
80
81/// Determines whether to show the healthbar
82pub fn should_show_healthbar(health: &Health) -> bool {
83    (health.current() - health.maximum()).abs() > Health::HEALTH_EPSILON
84        || health.current() < health.base_max()
85}
86/// Determines if there is decayed health being applied
87pub fn decayed_health_displayed(health: &Health) -> bool {
88    (1.0 - health.maximum() / health.base_max()) > 0.0
89}
90/// ui widget containing everything that goes over a character's head
91/// (Speech bubble, Name, Level, HP/energy bars, etc.)
92#[derive(WidgetCommon)]
93pub struct Overhead<'a> {
94    info: Option<Info<'a>>,
95    bubble: Option<&'a SpeechBubble>,
96    in_group: bool,
97    pulse: f32,
98    interaction_options: Vec<(GameInput, String)>,
99
100    i18n: &'a Localization,
101    imgs: &'a Imgs,
102    fonts: &'a Fonts,
103    time: &'a Time,
104    global_state: &'a GlobalState,
105
106    #[conrod(common_builder)]
107    common: widget::CommonBuilder,
108}
109
110impl<'a> Overhead<'a> {
111    pub fn new(
112        info: Option<Info<'a>>,
113        bubble: Option<&'a SpeechBubble>,
114        in_group: bool,
115        pulse: f32,
116        interaction_options: Vec<(GameInput, String)>,
117        i18n: &'a Localization,
118        imgs: &'a Imgs,
119        fonts: &'a Fonts,
120        time: &'a Time,
121        global_state: &'a GlobalState,
122    ) -> Self {
123        Self {
124            info,
125            bubble,
126            in_group,
127            pulse,
128            interaction_options,
129            i18n,
130            imgs,
131            fonts,
132            time,
133            global_state,
134            common: widget::CommonBuilder::default(),
135        }
136    }
137}
138
139pub struct State {
140    ids: Ids,
141}
142
143impl Ingameable for Overhead<'_> {
144    fn prim_count(&self) -> usize {
145        // Number of conrod primitives contained in the overhead display. TODO maybe
146        // this could be done automatically?
147
148        // HP related info
149        let info_ids = self.info.as_ref().map_or(0, |info| {
150            // + 2 Text::new for name
151            // + 1 Alignment Rectangle
152            let mut count_ids = 2 + 1;
153
154            // If Buff Info is shown:
155            // + 2 per buff (1 for buff and 1 for timer overlay) (only if there is no speech
156            //   bubble)
157            //   + 22 total with current max of 11 displayed buffs
158            if self.bubble.is_none() {
159                let buff_ids = info
160                    .buffs
161                    .as_ref()
162                    .map_or(0, |buffs| BuffIcon::icons_vec(buffs, info.stance).len());
163                count_ids += buff_ids.min(11) * 2;
164            }
165
166            // If HP Info is shown:
167            // + 3 for HP + fg + bg
168            // + 1 for level: either Text or Image <-- Not used currently, will be replaced
169            //   by something else
170            // + 1 for HP text
171            // If there's mana
172            //   + 1 Rect::new for mana
173            // + 1 if hardcore
174            if info.health.is_some_and(should_show_healthbar) {
175                count_ids += 5;
176                count_ids += info.energy.is_some() as usize;
177                count_ids += info.hardcore as usize;
178            }
179
180            // - 1 for decayed health overlay
181            count_ids += info.health.is_some_and(decayed_health_displayed) as usize;
182
183            // For KeyboardMouse:
184            // + 2 for text + bg
185            // For Controller:
186            // + 1 (anchor/alignment) + 4 (text lines) + 12 (icons) + 1 (bg) icons
187            //   calculated as (3 icons * 4 text lines)
188            if !self.interaction_options.is_empty() {
189                count_ids += match self.global_state.window.last_input() {
190                    LastInput::KeyboardMouse => 2,
191                    LastInput::Controller => 18,
192                };
193            }
194
195            count_ids
196        });
197
198        // + 2 Text::new for speech bubble
199        // + 1 Image::new for icon
200        // + 10 Image::new for speech bubble (9-slice + tail)
201        let bubble = if self.bubble.is_some() { 13 } else { 0 };
202
203        info_ids + bubble
204    }
205}
206
207impl Widget for Overhead<'_> {
208    type Event = ();
209    type State = State;
210    type Style = ();
211
212    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
213        State {
214            ids: Ids::new(id_gen),
215        }
216    }
217
218    fn style(&self) -> Self::Style {}
219
220    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
221        let widget::UpdateArgs { id, state, ui, .. } = args;
222        const BARSIZE: f64 = 2.0; // Scaling
223        const MANA_BAR_HEIGHT: f64 = BARSIZE * 1.5;
224        const MANA_BAR_Y: f64 = MANA_BAR_HEIGHT / 2.0;
225        if let Some(Info {
226            ref name,
227            health,
228            buffs,
229            energy,
230            combat_rating,
231            hardcore,
232            stance,
233        }) = self.info
234        {
235            // Used to set healthbar colours based on hp_percentage
236            let hp_percentage = health.map_or(100.0, |h| {
237                f64::from(h.current() / h.base_max().max(h.maximum()) * 100.0)
238            });
239            // Compare levels to decide if a skull is shown
240            let health_current = health.map_or(1.0, |h| f64::from(h.current()));
241            let health_max = health.map_or(1.0, |h| f64::from(h.maximum()));
242            let name_y = if (health_current - health_max).abs() < 1e-6 {
243                MANA_BAR_Y + 20.0
244            } else {
245                MANA_BAR_Y + 32.0
246            };
247            let font_size = if hp_percentage.abs() > 99.9 { 24 } else { 20 };
248            // Show K for numbers above 10^3 and truncate them
249            // Show M for numbers above 10^6 and truncate them
250            let health_cur_txt = if self.global_state.settings.interface.use_health_prefixes {
251                match health_current as u32 {
252                    0..=999 => format!("{:.0}", health_current.max(1.0)),
253                    1000..=999999 => format!("{:.0}K", (health_current / 1000.0).max(1.0)),
254                    _ => format!("{:.0}M", (health_current / 1.0e6).max(1.0)),
255                }
256            } else {
257                format!("{:.0}", health_current.max(1.0))
258            };
259            let health_max_txt = if self.global_state.settings.interface.use_health_prefixes {
260                match health_max as u32 {
261                    0..=999 => format!("{:.0}", health_max.max(1.0)),
262                    1000..=999999 => format!("{:.0}K", (health_max / 1000.0).max(1.0)),
263                    _ => format!("{:.0}M", (health_max / 1.0e6).max(1.0)),
264                }
265            } else {
266                format!("{:.0}", health_max.max(1.0))
267            };
268            // Buffs
269            // Alignment
270            let buff_icons = buffs
271                .as_ref()
272                .map(|buffs| BuffIcon::icons_vec(buffs, stance))
273                .unwrap_or_default();
274            let buff_count = buff_icons.len().min(11);
275            Rectangle::fill_with([168.0, 100.0], color::TRANSPARENT)
276                .x_y(-1.0, name_y + 60.0)
277                .parent(id)
278                .set(state.ids.buffs_align, ui);
279
280            let generator = &mut ui.widget_id_generator();
281            if state.ids.buffs.len() < buff_count {
282                state.update(|state| state.ids.buffs.resize(buff_count, generator));
283            };
284            if state.ids.buff_timers.len() < buff_count {
285                state.update(|state| state.ids.buff_timers.resize(buff_count, generator));
286            };
287
288            let buff_ani = ((self.pulse * 4.0).cos() * 0.5 + 0.8) + 0.5; //Animation timer
289            let pulsating_col = Color::Rgba(1.0, 1.0, 1.0, buff_ani);
290            let norm_col = Color::Rgba(1.0, 1.0, 1.0, 1.0);
291            // Create Buff Widgets
292            if self.bubble.is_none() {
293                state
294                    .ids
295                    .buffs
296                    .iter()
297                    .copied()
298                    .zip(state.ids.buff_timers.iter().copied())
299                    .zip(buff_icons.iter())
300                    .enumerate()
301                    .for_each(|(i, ((id, timer_id), buff))| {
302                        // Limit displayed buffs
303                        let max_duration = buff.kind.max_duration();
304                        let current_duration = buff.end_time.map(|end| end - self.time.0);
305                        let duration_percentage = current_duration.map_or(1000.0, |cur| {
306                            max_duration.map_or(1000.0, |max| cur / max.0 * 1000.0)
307                        }) as u32; // Percentage to determine which frame of the timer overlay is displayed
308                        let buff_img = buff.kind.image(self.imgs);
309                        let buff_widget = Image::new(buff_img).w_h(20.0, 20.0);
310                        // Sort buffs into rows of 5 slots
311                        let x = i % 5;
312                        let y = i / 5;
313                        let buff_widget = buff_widget.bottom_left_with_margins_on(
314                            state.ids.buffs_align,
315                            0.0 + y as f64 * (21.0),
316                            0.0 + x as f64 * (21.0),
317                        );
318                        buff_widget
319                            .color(if current_duration.is_some_and(|cur| cur < 10.0) {
320                                Some(pulsating_col)
321                            } else {
322                                Some(norm_col)
323                            })
324                            .set(id, ui);
325
326                        Image::new(match duration_percentage as u64 {
327                            875..=1000 => self.imgs.nothing, // 8/8
328                            750..=874 => self.imgs.buff_0,   // 7/8
329                            625..=749 => self.imgs.buff_1,   // 6/8
330                            500..=624 => self.imgs.buff_2,   // 5/8
331                            375..=499 => self.imgs.buff_3,   // 4/8
332                            250..=374 => self.imgs.buff_4,   // 3/8
333                            125..=249 => self.imgs.buff_5,   // 2/8
334                            0..=124 => self.imgs.buff_6,     // 1/8
335                            _ => self.imgs.nothing,
336                        })
337                        .w_h(20.0, 20.0)
338                        .middle_of(id)
339                        .set(timer_id, ui);
340                    });
341            }
342            // Name
343            Text::new(name.as_deref().unwrap_or(""))
344                //Text::new(&format!("{} [{:?}]", name, combat_rating)) // <- Uncomment to debug combat ratings
345                .font_id(self.fonts.cyri.conrod_id)
346                .font_size(font_size)
347                .color(Color::Rgba(0.0, 0.0, 0.0, 1.0))
348                .x_y(-1.0, name_y)
349                .parent(id)
350                .set(state.ids.name_bg, ui);
351            Text::new(name.as_deref().unwrap_or(""))
352                //Text::new(&format!("{} [{:?}]", name, combat_rating)) // <- Uncomment to debug combat ratings
353                .font_id(self.fonts.cyri.conrod_id)
354                .font_size(font_size)
355                .color(if self.in_group {
356                    GROUP_MEMBER
357                /*} 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
358                DEFAULT_NPC*/
359                } else {
360                    DEFAULT_NPC
361                })
362                .x_y(0.0, name_y + 1.0)
363                .parent(id)
364                .set(state.ids.name, ui);
365
366            match health {
367                Some(health)
368                    if should_show_healthbar(health) || decayed_health_displayed(health) =>
369                {
370                    // Show HP Bar
371                    let hp_ani = (self.pulse * 4.0/* speed factor */).cos() * 0.5 + 1.0; //Animation timer
372                    let crit_hp_color: Color = Color::Rgba(0.93, 0.59, 0.03, hp_ani);
373                    let decayed_health = f64::from(1.0 - health.maximum() / health.base_max());
374                    // Background
375                    Image::new(if self.in_group {self.imgs.health_bar_group_bg} else {self.imgs.enemy_health_bg})
376                        .w_h(84.0 * BARSIZE, 10.0 * BARSIZE)
377                        .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5)
378                        .color(Some(Color::Rgba(0.1, 0.1, 0.1, 0.8)))
379                        .parent(id)
380                        .set(state.ids.health_bar_bg, ui);
381
382                    // % HP Filling
383                    let size_factor = (hp_percentage / 100.0) * BARSIZE;
384                    let w = if self.in_group {
385                        82.0 * size_factor
386                    } else {
387                        73.0 * size_factor
388                    };
389                    let h = 6.0 * BARSIZE;
390                    let x = if self.in_group {
391                        (0.0 + (hp_percentage / 100.0 * 41.0 - 41.0)) * BARSIZE
392                    } else {
393                        (4.5 + (hp_percentage / 100.0 * 36.45 - 36.45)) * BARSIZE
394                    };
395                    Image::new(self.imgs.enemy_bar)
396                        .w_h(w, h)
397                        .x_y(x, MANA_BAR_Y + 8.0)
398                        .color(if self.in_group {
399                            // Different HP bar colors only for group members
400                            Some(match hp_percentage {
401                                x if (0.0..25.0).contains(&x) => crit_hp_color,
402                                x if (25.0..50.0).contains(&x) => LOW_HP_COLOR,
403                                _ => HP_COLOR,
404                            })
405                        } else {
406                            Some(ENEMY_HP_COLOR)
407                        })
408                        .parent(id)
409                        .set(state.ids.health_bar, ui);
410
411                    if decayed_health > 0.0 {
412                        let x_decayed = if self.in_group {
413                            (0.0 - (decayed_health * 41.0 - 41.0)) * BARSIZE
414                        } else {
415                            (4.5 - (decayed_health * 36.45 - 36.45)) * BARSIZE
416                        };
417
418                        let decay_bar_len = decayed_health
419                            * if self.in_group {
420                                82.0 * BARSIZE
421                            } else {
422                                73.0 * BARSIZE
423                            };
424                        Image::new(self.imgs.enemy_bar)
425                            .w_h(decay_bar_len, h)
426                            .x_y(x_decayed, MANA_BAR_Y + 8.0)
427                            .color(Some(QUALITY_EPIC))
428                            .parent(id)
429                            .set(state.ids.decay_bar, ui);
430                    }
431                    let mut txt = format!("{}/{}", health_cur_txt, health_max_txt);
432                    if health.is_dead {
433                        txt = self.i18n.get_msg("hud-group-dead").to_string()
434                    };
435                    Text::new(&txt)
436                        .mid_top_with_margin_on(state.ids.health_bar_bg, 2.0)
437                        .font_size(10)
438                        .font_id(self.fonts.cyri.conrod_id)
439                        .color(TEXT_COLOR)
440                        .parent(id)
441                        .set(state.ids.health_txt, ui);
442
443                    // % Mana Filling
444                    if let Some(energy) = energy {
445                        let energy_factor = f64::from(energy.current() / energy.maximum());
446                        let size_factor = energy_factor * BARSIZE;
447                        let w = if self.in_group {
448                            80.0 * size_factor
449                        } else {
450                            72.0 * size_factor
451                        };
452                        let x = if self.in_group {
453                            ((0.0 + (energy_factor * 40.0)) - 40.0) * BARSIZE
454                        } else {
455                            ((3.5 + (energy_factor * 36.5)) - 36.45) * BARSIZE
456                        };
457                        Rectangle::fill_with([w, MANA_BAR_HEIGHT], STAMINA_COLOR)
458                            .x_y(
459                                x, MANA_BAR_Y, //-32.0,
460                            )
461                            .parent(id)
462                            .set(state.ids.mana_bar, ui);
463                    }
464
465                    // Foreground
466                    Image::new(if self.in_group {self.imgs.health_bar_group} else {self.imgs.enemy_health})
467                .w_h(84.0 * BARSIZE, 10.0 * BARSIZE)
468                .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5)
469                .color(Some(Color::Rgba(1.0, 1.0, 1.0, 0.99)))
470                .parent(id)
471                .set(state.ids.health_bar_fg, ui);
472
473                    if let Some(combat_rating) = combat_rating {
474                        let indicator_col = cr_color(combat_rating);
475                        let artifact_diffculty = 122.0;
476
477                        if combat_rating > artifact_diffculty && !self.in_group {
478                            let skull_ani =
479                                ((self.pulse * 0.7/* speed factor */).cos() * 0.5 + 0.5) * 10.0; //Animation timer
480                            Image::new(if skull_ani as i32 == 1 && rand::random::<f32>() < 0.9 {
481                                self.imgs.skull_2
482                            } else {
483                                self.imgs.skull
484                            })
485                            .w_h(18.0 * BARSIZE, 18.0 * BARSIZE)
486                            .x_y(-39.0 * BARSIZE, MANA_BAR_Y + 7.0)
487                            .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0)))
488                            .parent(id)
489                            .set(state.ids.level_skull, ui);
490                        } else {
491                            Image::new(if self.in_group {
492                                self.imgs.nothing
493                            } else {
494                                self.imgs.combat_rating_ico
495                            })
496                            .w_h(7.0 * BARSIZE, 7.0 * BARSIZE)
497                            .x_y(-37.0 * BARSIZE, MANA_BAR_Y + 6.0)
498                            .color(Some(indicator_col))
499                            .parent(id)
500                            .set(state.ids.level, ui);
501                        }
502                    }
503
504                    if hardcore {
505                        Image::new(self.imgs.hardcore)
506                            .w_h(18.0 * BARSIZE, 18.0 * BARSIZE)
507                            .x_y(39.0 * BARSIZE, MANA_BAR_Y + 13.0)
508                            .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0)))
509                            .parent(id)
510                            .set(state.ids.hardcore, ui);
511                    }
512                },
513                _ => {},
514            }
515
516            // Interaction hints
517            if !self.interaction_options.is_empty() {
518                let scale = 30.0;
519                let btn_rect_size = scale * 0.8;
520                let btn_font_size = scale * 0.6;
521                let btn_rect_pos_y;
522                let btn_radius = btn_rect_size / 5.0;
523                let btn_color = Color::Rgba(0.0, 0.0, 0.0, 0.8);
524                let mut max_w = btn_rect_size;
525                let mut max_h = 0.0;
526                let mut box_offset = 0.0;
527
528                match self.global_state.window.last_input() {
529                    LastInput::KeyboardMouse => {
530                        let texts = self
531                            .interaction_options
532                            .iter()
533                            .filter_map(|(input, action)| {
534                                Some((
535                                    self.global_state.settings.controls.get_binding(*input)?,
536                                    action,
537                                ))
538                            })
539                            .map(|(input, action)| {
540                                format!("{}  {}", input.display_string(), action)
541                            })
542                            .collect::<Vec<_>>()
543                            .join("\n");
544
545                        let hints_text = Text::new(&texts)
546                            .font_id(self.fonts.cyri.conrod_id)
547                            .font_size(btn_font_size as u32)
548                            .color(TEXT_COLOR)
549                            .parent(id)
550                            .down_from(
551                                self.info.map_or(state.ids.name, |info| {
552                                    if info.health.is_some_and(should_show_healthbar) {
553                                        if info.energy.is_some() {
554                                            state.ids.mana_bar
555                                        } else {
556                                            state.ids.health_bar
557                                        }
558                                    } else {
559                                        state.ids.name
560                                    }
561                                }),
562                                12.0,
563                            )
564                            .align_middle_x_of(state.ids.name)
565                            .depth(1.0);
566
567                        let [w, h] = hints_text.get_wh(ui).unwrap_or([btn_rect_size; 2]);
568                        max_w = max_w.max(w);
569                        max_h += h;
570                        hints_text.set(state.ids.interaction_hints, ui);
571                        btn_rect_pos_y = 0.0;
572                    },
573                    LastInput::Controller => {
574                        // because in-line images are not easily supported, the controller icons are
575                        // rendered left of the text; thus, the text has to be listed line by line
576                        // instead of all being joined together.
577                        // There can be up to 4 lines of text, and up to 3 icons. Because we don't
578                        // know which npc is being interacted with and that we allow input
579                        // rebinding, we don't know how many lines of text or icons to expect.
580                        // Therefore, we reserve the maximum possible number of widget id's from
581                        // conrod, and use up any we don't use with blank spaces.
582
583                        let max_controller_text = 4; // 4 npc interactions at most (e.g., mount, stay, trade, pet)
584                        if state.ids.btns.len() < max_controller_text {
585                            state.update(|state| {
586                                state
587                                    .ids
588                                    .btns
589                                    .resize(max_controller_text, &mut ui.widget_id_generator());
590                            })
591                        }
592
593                        let icns_size = max_controller_text * 3; // main icon + 2 modifier buttons
594                        if state.ids.icns.len() < icns_size {
595                            state.update(|state| {
596                                state
597                                    .ids
598                                    .icns
599                                    .resize(icns_size, &mut ui.widget_id_generator());
600                            })
601                        }
602
603                        let icon_handler = IconHandler::new(self.global_state, self.imgs);
604
605                        // anchors the text under the appropriate UI elements
606                        let anchor_text = Text::new("")
607                            .font_id(self.fonts.cyri.conrod_id)
608                            .font_size(btn_font_size as u32)
609                            .color(TEXT_COLOR)
610                            .parent(id)
611                            .down_from(
612                                self.info.map_or(state.ids.name, |info| {
613                                    if info.health.is_some_and(should_show_healthbar) {
614                                        if info.energy.is_some() {
615                                            state.ids.mana_bar
616                                        } else {
617                                            state.ids.health_bar
618                                        }
619                                    } else {
620                                        state.ids.name
621                                    }
622                                }),
623                                12.0,
624                            )
625                            .align_middle_x_of(state.ids.name)
626                            .depth(1.0);
627
628                        anchor_text.set(state.ids.interaction_hints, ui);
629                        let mut down_from_id = state.ids.interaction_hints;
630                        let mut icons_w: u8 = 0;
631                        let mut first_text_w = 0.0;
632
633                        // loops through all reserved id's for max_controller_text
634                        // even if the text is not used, the id should be used to keep conrod from
635                        // freaking out
636                        for i in 0..max_controller_text {
637                            let text_id = state.ids.btns[i];
638                            let idx_icns = i * 3;
639                            let icon_ids = LayerIconIds {
640                                main: state.ids.icns[idx_icns],
641                                modifier1: state.ids.icns[idx_icns + 1],
642                                modifier2: state.ids.icns[idx_icns + 2],
643                            };
644
645                            // get the data for this row if it exists
646                            let row_data = self.interaction_options.get(i);
647                            let action_text =
648                                row_data.map(|(_, action)| action.as_str()).unwrap_or("");
649
650                            // draw the text (actual action or empty string)
651                            let mut hints_text = Text::new(action_text)
652                                .font_id(self.fonts.cyri.conrod_id)
653                                .font_size(btn_font_size as u32)
654                                .color(TEXT_COLOR)
655                                .parent(id)
656                                .depth(1.0);
657
658                            if i == 0 {
659                                // position the first line on the anchor
660                                hints_text = hints_text.middle_of(down_from_id);
661                            } else {
662                                // position subsequent lines below the previous
663                                hints_text = hints_text.down_from(down_from_id, 1.0);
664                            }
665
666                            // update math only if there's real data
667                            if let Some((input, _)) = row_data {
668                                let [w, h] = hints_text.get_wh(ui).unwrap_or([btn_rect_size; 2]);
669                                max_w = max_w.max(w);
670                                max_h += h;
671
672                                if i == 0 {
673                                    first_text_w = w;
674                                }
675
676                                hints_text.set(text_id, ui);
677                                down_from_id = text_id;
678
679                                let count = icon_handler.set_controller_icons_left(
680                                    *input, 17.0, text_id, &icon_ids, ui,
681                                );
682                                icons_w = icons_w.max(count);
683                            } else {
684                                hints_text.set(text_id, ui);
685                                down_from_id = text_id;
686
687                                // render transparant widgets to keep conrod from freaking out
688                                icon_handler
689                                    .set_controller_icons_left_none(17.0, text_id, &icon_ids, ui);
690                            }
691                        }
692
693                        let icon_largest_width = icons_w as f64 * 21.0;
694                        let centroid_difference = (max_w / 2.0) - (first_text_w / 2.0);
695                        let offset = icon_largest_width / 2.0;
696                        box_offset = -(centroid_difference - offset);
697
698                        max_w += icon_largest_width;
699                        max_h = max_h.max(btn_rect_size);
700                        btn_rect_pos_y = (max_h - btn_font_size + 2.0) / 2.0;
701                    },
702                }
703
704                RoundedRectangle::fill_with(
705                    [max_w + btn_radius * 2.0, max_h + btn_radius * 2.0],
706                    btn_radius,
707                    btn_color,
708                )
709                .depth(2.0)
710                .x_y_relative_to(
711                    state.ids.interaction_hints,
712                    0.0 - box_offset,
713                    0.0 - btn_rect_pos_y,
714                )
715                .parent(id)
716                .set(state.ids.interaction_hints_bg, ui);
717            }
718        }
719        // Speech bubble
720        if let Some(bubble) = self.bubble {
721            let dark_mode = self.global_state.settings.interface.speech_bubble_dark_mode;
722            let bubble_contents: String = self.i18n.get_content(bubble.content());
723            let (text_color, shadow_color) = bubble_color(bubble, dark_mode);
724            let mut text = Text::new(&bubble_contents)
725                .color(text_color)
726                .font_id(self.fonts.cyri.conrod_id)
727                .font_size(18)
728                .up_from(state.ids.name, 26.0)
729                .x_align_to(state.ids.name, Align::Middle)
730                .parent(id);
731
732            if let Some(w) = text.get_w(ui)
733                && w > MAX_BUBBLE_WIDTH
734            {
735                text = text.w(MAX_BUBBLE_WIDTH);
736            }
737            Image::new(if dark_mode {
738                self.imgs.dark_bubble_top_left
739            } else {
740                self.imgs.speech_bubble_top_left
741            })
742            .w_h(16.0, 16.0)
743            .top_left_with_margin_on(state.ids.speech_bubble_text, -20.0)
744            .parent(id)
745            .set(state.ids.speech_bubble_top_left, ui);
746            Image::new(if dark_mode {
747                self.imgs.dark_bubble_top
748            } else {
749                self.imgs.speech_bubble_top
750            })
751            .h(16.0)
752            .padded_w_of(state.ids.speech_bubble_text, -4.0)
753            .mid_top_with_margin_on(state.ids.speech_bubble_text, -20.0)
754            .parent(id)
755            .set(state.ids.speech_bubble_top, ui);
756            Image::new(if dark_mode {
757                self.imgs.dark_bubble_top_right
758            } else {
759                self.imgs.speech_bubble_top_right
760            })
761            .w_h(16.0, 16.0)
762            .top_right_with_margin_on(state.ids.speech_bubble_text, -20.0)
763            .parent(id)
764            .set(state.ids.speech_bubble_top_right, ui);
765            Image::new(if dark_mode {
766                self.imgs.dark_bubble_left
767            } else {
768                self.imgs.speech_bubble_left
769            })
770            .w(16.0)
771            .padded_h_of(state.ids.speech_bubble_text, -4.0)
772            .mid_left_with_margin_on(state.ids.speech_bubble_text, -20.0)
773            .parent(id)
774            .set(state.ids.speech_bubble_left, ui);
775            Image::new(if dark_mode {
776                self.imgs.dark_bubble_mid
777            } else {
778                self.imgs.speech_bubble_mid
779            })
780            .padded_wh_of(state.ids.speech_bubble_text, -4.0)
781            .top_left_with_margin_on(state.ids.speech_bubble_text, -4.0)
782            .parent(id)
783            .set(state.ids.speech_bubble_mid, ui);
784            Image::new(if dark_mode {
785                self.imgs.dark_bubble_right
786            } else {
787                self.imgs.speech_bubble_right
788            })
789            .w(16.0)
790            .padded_h_of(state.ids.speech_bubble_text, -4.0)
791            .mid_right_with_margin_on(state.ids.speech_bubble_text, -20.0)
792            .parent(id)
793            .set(state.ids.speech_bubble_right, ui);
794            Image::new(if dark_mode {
795                self.imgs.dark_bubble_bottom_left
796            } else {
797                self.imgs.speech_bubble_bottom_left
798            })
799            .w_h(16.0, 16.0)
800            .bottom_left_with_margin_on(state.ids.speech_bubble_text, -20.0)
801            .parent(id)
802            .set(state.ids.speech_bubble_bottom_left, ui);
803            Image::new(if dark_mode {
804                self.imgs.dark_bubble_bottom
805            } else {
806                self.imgs.speech_bubble_bottom
807            })
808            .h(16.0)
809            .padded_w_of(state.ids.speech_bubble_text, -4.0)
810            .mid_bottom_with_margin_on(state.ids.speech_bubble_text, -20.0)
811            .parent(id)
812            .set(state.ids.speech_bubble_bottom, ui);
813            Image::new(if dark_mode {
814                self.imgs.dark_bubble_bottom_right
815            } else {
816                self.imgs.speech_bubble_bottom_right
817            })
818            .w_h(16.0, 16.0)
819            .bottom_right_with_margin_on(state.ids.speech_bubble_text, -20.0)
820            .parent(id)
821            .set(state.ids.speech_bubble_bottom_right, ui);
822            let tail = Image::new(if dark_mode {
823                self.imgs.dark_bubble_tail
824            } else {
825                self.imgs.speech_bubble_tail
826            })
827            .parent(id)
828            .mid_bottom_with_margin_on(state.ids.speech_bubble_text, -32.0);
829
830            if dark_mode {
831                tail.w_h(22.0, 13.0)
832            } else {
833                tail.w_h(22.0, 28.0)
834            }
835            .set(state.ids.speech_bubble_tail, ui);
836
837            let mut text_shadow = Text::new(&bubble_contents)
838                .color(shadow_color)
839                .font_id(self.fonts.cyri.conrod_id)
840                .font_size(18)
841                .x_relative_to(state.ids.speech_bubble_text, 1.0)
842                .y_relative_to(state.ids.speech_bubble_text, -1.0)
843                .parent(id);
844            // Move text to front (conrod depth is lowest first; not a z-index)
845            text.depth(text_shadow.get_depth() - 1.0)
846                .set(state.ids.speech_bubble_text, ui);
847            if let Some(w) = text_shadow.get_w(ui)
848                && w > MAX_BUBBLE_WIDTH
849            {
850                text_shadow = text_shadow.w(MAX_BUBBLE_WIDTH);
851            }
852            text_shadow.set(state.ids.speech_bubble_shadow, ui);
853            let icon = if self.global_state.settings.interface.speech_bubble_icon {
854                bubble_icon(bubble, self.imgs)
855            } else {
856                self.imgs.nothing
857            };
858            Image::new(icon)
859                    .w_h(16.0, 16.0)
860                    .top_left_with_margin_on(state.ids.speech_bubble_text, -16.0)
861                    // TODO: Figure out whether this should be parented.
862                    // .parent(id)
863                    .set(state.ids.speech_bubble_icon, ui);
864        }
865    }
866}
867
868fn bubble_color(bubble: &SpeechBubble, dark_mode: bool) -> (Color, Color) {
869    let light_color = match bubble.icon {
870        SpeechBubbleType::Tell => TELL_COLOR,
871        SpeechBubbleType::Say => SAY_COLOR,
872        SpeechBubbleType::Region => REGION_COLOR,
873        SpeechBubbleType::Group => GROUP_COLOR,
874        SpeechBubbleType::Faction => FACTION_COLOR,
875        SpeechBubbleType::World
876        | SpeechBubbleType::Quest
877        | SpeechBubbleType::Trade
878        | SpeechBubbleType::None => TEXT_COLOR,
879    };
880    if dark_mode {
881        (light_color, TEXT_BG)
882    } else {
883        (TEXT_BG, light_color)
884    }
885}
886
887fn bubble_icon(sb: &SpeechBubble, imgs: &Imgs) -> conrod_core::image::Id {
888    match sb.icon {
889        // One for each chat mode
890        SpeechBubbleType::Tell => imgs.chat_tell_small,
891        SpeechBubbleType::Say => imgs.chat_say_small,
892        SpeechBubbleType::Region => imgs.chat_region_small,
893        SpeechBubbleType::Group => imgs.chat_group_small,
894        SpeechBubbleType::Faction => imgs.chat_faction_small,
895        SpeechBubbleType::World => imgs.chat_world_small,
896        SpeechBubbleType::Quest => imgs.nothing, // TODO not implemented
897        SpeechBubbleType::Trade => imgs.nothing, // TODO not implemented
898        SpeechBubbleType::None => imgs.nothing,  // No icon (default for npcs)
899    }
900}