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