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