Skip to main content

veloren_voxygen/hud/
overhead.rs

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