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