veloren_voxygen/hud/
loot_scroller.rs

1use super::{
2    HudInfo, Show, TEXT_COLOR, Windows, animate_by_pulse, get_quality_col,
3    img_ids::{Imgs, ImgsRot},
4    item_imgs::ItemImgs,
5    util,
6};
7use crate::ui::{ImageFrame, ItemTooltip, ItemTooltipManager, ItemTooltipable, fonts::Fonts};
8use client::Client;
9use common::{
10    comp::{
11        FrontendItem, Inventory,
12        inventory::item::{ItemDesc, ItemI18n, MaterialStatManifest, Quality},
13    },
14    recipe::RecipeBookManifest,
15    uid::Uid,
16};
17use common_net::sync::WorldSyncExt;
18use conrod_core::{
19    Color, Colorable, Positionable, Sizeable, Widget, WidgetCommon, color,
20    position::Dimension,
21    widget::{self, Image, List, Rectangle, Scrollbar, Text},
22    widget_ids,
23};
24use i18n::Localization;
25use std::{collections::VecDeque, num::NonZeroU32};
26
27widget_ids! {
28    struct Ids{
29        frame,
30        message_box,
31        scrollbar,
32        message_icons[],
33        message_icon_bgs[],
34        message_icon_frames[],
35        message_texts[],
36        message_text_shadows[],
37    }
38}
39
40const MAX_MESSAGES: usize = 50;
41
42const BOX_WIDTH: f64 = 300.0;
43const BOX_HEIGHT: f64 = 350.0;
44
45const ICON_BG_SIZE: f64 = 33.0;
46const ICON_SIZE: f64 = 30.0;
47const ICON_LABEL_SPACER: f64 = 7.0;
48
49const MESSAGE_VERTICAL_PADDING: f64 = 1.0;
50
51const HOVER_FADE_OUT_TIME: f32 = 2.0;
52const MESSAGE_FADE_OUT_TIME: f32 = 4.5;
53const AUTO_SHOW_FADE_OUT_TIME: f32 = 1.0;
54
55const MAX_MERGE_TIME: f32 = MESSAGE_FADE_OUT_TIME;
56
57#[derive(WidgetCommon)]
58pub struct LootScroller<'a> {
59    new_messages: &'a mut VecDeque<LootMessage>,
60
61    client: &'a Client,
62    info: &'a HudInfo,
63    show: &'a Show,
64    imgs: &'a Imgs,
65    item_imgs: &'a ItemImgs,
66    rot_imgs: &'a ImgsRot,
67    fonts: &'a Fonts,
68    localized_strings: &'a Localization,
69    item_i18n: &'a ItemI18n,
70    msm: &'a MaterialStatManifest,
71    rbm: &'a RecipeBookManifest,
72    inventory: Option<&'a Inventory>,
73    item_tooltip_manager: &'a mut ItemTooltipManager,
74    pulse: f32,
75
76    #[conrod(common_builder)]
77    common: widget::CommonBuilder,
78}
79impl<'a> LootScroller<'a> {
80    pub fn new(
81        new_messages: &'a mut VecDeque<LootMessage>,
82        client: &'a Client,
83        info: &'a HudInfo,
84        show: &'a Show,
85        imgs: &'a Imgs,
86        item_imgs: &'a ItemImgs,
87        rot_imgs: &'a ImgsRot,
88        fonts: &'a Fonts,
89        localized_strings: &'a Localization,
90        item_i18n: &'a ItemI18n,
91        msm: &'a MaterialStatManifest,
92        rbm: &'a RecipeBookManifest,
93        inventory: Option<&'a Inventory>,
94        item_tooltip_manager: &'a mut ItemTooltipManager,
95        pulse: f32,
96    ) -> Self {
97        Self {
98            new_messages,
99            client,
100            info,
101            show,
102            imgs,
103            item_imgs,
104            rot_imgs,
105            fonts,
106            localized_strings,
107            item_i18n,
108            msm,
109            rbm,
110            inventory,
111            item_tooltip_manager,
112            pulse,
113            common: widget::CommonBuilder::default(),
114        }
115    }
116}
117
118#[derive(Debug, PartialEq)]
119pub struct LootMessage {
120    pub item: FrontendItem,
121    pub amount: NonZeroU32,
122    pub taken_by: Uid,
123}
124
125pub struct State {
126    ids: Ids,
127    messages: VecDeque<(LootMessage, f32)>, // (message, timestamp)
128
129    last_hover_pulse: Option<f32>,
130    last_auto_show_pulse: Option<f32>, // auto show if (for example) bag is open
131}
132
133impl Widget for LootScroller<'_> {
134    type Event = ();
135    type State = State;
136    type Style = ();
137
138    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
139        State {
140            ids: Ids::new(id_gen),
141            messages: VecDeque::new(),
142            last_hover_pulse: None,
143            last_auto_show_pulse: None,
144        }
145    }
146
147    fn style(&self) -> Self::Style {}
148
149    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
150        let widget::UpdateArgs { state, ui, .. } = args;
151
152        // Tooltips
153        let item_tooltip = ItemTooltip::new(
154            {
155                // Edge images [t, b, r, l]
156                // Corner images [tr, tl, br, bl]
157                let edge = &self.rot_imgs.tt_side;
158                let corner = &self.rot_imgs.tt_corner;
159                ImageFrame::new(
160                    [edge.cw180, edge.none, edge.cw270, edge.cw90],
161                    [corner.none, corner.cw270, corner.cw90, corner.cw180],
162                    Color::Rgba(0.08, 0.07, 0.04, 1.0),
163                    5.0,
164                )
165            },
166            self.client,
167            self.info,
168            self.imgs,
169            self.item_imgs,
170            self.pulse,
171            self.msm,
172            self.rbm,
173            self.inventory,
174            self.localized_strings,
175            self.item_i18n,
176        )
177        .title_font_size(self.fonts.cyri.scale(20))
178        .parent(ui.window)
179        .desc_font_size(self.fonts.cyri.scale(12))
180        .font_id(self.fonts.cyri.conrod_id)
181        .desc_text_color(TEXT_COLOR);
182
183        if !self.new_messages.is_empty() {
184            let pulse = self.pulse;
185            let oldest_merge_pulse = pulse - MAX_MERGE_TIME;
186
187            state.update(|s| {
188                s.messages.retain(|(message, t)| {
189                    if *t >= oldest_merge_pulse {
190                        if let Some(i) = self.new_messages.iter().position(|m| {
191                            m.item.item_definition_id() == message.item.item_definition_id()
192                                && m.taken_by == message.taken_by
193                        }) {
194                            self.new_messages[i].amount = self.new_messages[i]
195                                .amount
196                                .saturating_add(message.amount.get());
197                            false
198                        } else {
199                            true
200                        }
201                    } else {
202                        true
203                    }
204                });
205                s.messages
206                    .extend(self.new_messages.drain(..).map(|message| (message, pulse)));
207                while s.messages.len() > MAX_MESSAGES {
208                    s.messages.pop_front();
209                }
210            });
211            ui.scroll_widget(state.ids.message_box, [0.0, f64::MAX]);
212        }
213
214        // check if it collides with other windows
215        if self.show.diary
216            || self.show.map
217            || self.show.open_windows != Windows::None
218            || self.show.social
219            || self.show.trade
220        {
221            if state.last_hover_pulse.is_some() || state.last_auto_show_pulse.is_some() {
222                state.update(|s| {
223                    s.last_hover_pulse = None;
224                    s.last_auto_show_pulse = None;
225                });
226            }
227        } else {
228            //check if hovered
229            if ui
230                .rect_of(state.ids.message_box)
231                .map(|r| r.pad_left(-6.0))
232                .is_some_and(|r| r.is_over(ui.global_input().current.mouse.xy))
233            {
234                state.update(|s| s.last_hover_pulse = Some(self.pulse));
235            }
236
237            if state.ids.message_icons.len() < state.messages.len() {
238                state.update(|s| {
239                    s.ids
240                        .message_icons
241                        .resize(s.messages.len(), &mut ui.widget_id_generator())
242                });
243            }
244            if state.ids.message_icon_bgs.len() < state.messages.len() {
245                state.update(|s| {
246                    s.ids
247                        .message_icon_bgs
248                        .resize(s.messages.len(), &mut ui.widget_id_generator())
249                });
250            }
251            if state.ids.message_icon_frames.len() < state.messages.len() {
252                state.update(|s| {
253                    s.ids
254                        .message_icon_frames
255                        .resize(s.messages.len(), &mut ui.widget_id_generator())
256                });
257            }
258            if state.ids.message_texts.len() < state.messages.len() {
259                state.update(|s| {
260                    s.ids
261                        .message_texts
262                        .resize(s.messages.len(), &mut ui.widget_id_generator())
263                });
264            }
265            if state.ids.message_text_shadows.len() < state.messages.len() {
266                state.update(|s| {
267                    s.ids
268                        .message_text_shadows
269                        .resize(s.messages.len(), &mut ui.widget_id_generator())
270                });
271            }
272
273            let hover_age = state
274                .last_hover_pulse
275                .map_or(1.0, |t| (self.pulse - t) / HOVER_FADE_OUT_TIME);
276            let auto_show_age = state
277                .last_auto_show_pulse
278                .map_or(1.0, |t| (self.pulse - t) / AUTO_SHOW_FADE_OUT_TIME);
279
280            let show_all_age = hover_age.min(auto_show_age);
281
282            let messages_to_display = state
283                .messages
284                .iter()
285                .rev()
286                .map(|(message, t)| {
287                    let age = (self.pulse - t) / MESSAGE_FADE_OUT_TIME;
288                    (message, age)
289                })
290                .filter(|(_, age)| age.min(show_all_age) < 1.0)
291                .collect::<Vec<_>>();
292
293            let (mut list_messages, _) = List::flow_up(messages_to_display.len())
294                .w_h(BOX_WIDTH, BOX_HEIGHT)
295                .scroll_kids_vertically()
296                .bottom_left_with_margins_on(ui.window, 308.0, 20.0)
297                .set(state.ids.message_box, ui);
298
299            //only show scrollbar if it is being hovered and needed
300            if show_all_age < 1.0
301                && ui
302                    .widget_graph()
303                    .widget(state.ids.message_box)
304                    .and_then(|w| w.maybe_y_scroll_state)
305                    .is_some_and(|s| s.scrollable_range_len > BOX_HEIGHT)
306            {
307                Scrollbar::y_axis(state.ids.message_box)
308                    .thickness(5.0)
309                    .rgba(0.33, 0.33, 0.33, 1.0 - show_all_age.powi(4))
310                    .left_from(state.ids.message_box, 1.0)
311                    .set(state.ids.scrollbar, ui);
312            }
313
314            let stats = self.client.state().read_storage::<common::comp::Stats>();
315
316            while let Some(list_message) = list_messages.next(ui) {
317                let i = list_message.i;
318
319                let (message, age) = messages_to_display[i];
320                let LootMessage {
321                    item,
322                    amount,
323                    taken_by,
324                } = message;
325
326                let alpha = 1.0 - age.min(show_all_age).powi(4);
327
328                let brightness = 1.0 / (age / 0.05 - 1.0).abs().clamp(0.01, 1.0);
329
330                let shade_color = |color: Color| {
331                    let color::Hsla(hue, sat, lum, alp) = color.to_hsl();
332                    color::hsla(hue, sat / brightness, lum * brightness.sqrt(), alp * alpha)
333                };
334
335                let quality_col_image = match item.quality() {
336                    Quality::Low => self.imgs.inv_slot_grey,
337                    Quality::Common => self.imgs.inv_slot_common,
338                    Quality::Moderate => self.imgs.inv_slot_green,
339                    Quality::High => self.imgs.inv_slot_blue,
340                    Quality::Epic => self.imgs.inv_slot_purple,
341                    Quality::Legendary => self.imgs.inv_slot_gold,
342                    Quality::Artifact => self.imgs.inv_slot_orange,
343                    _ => self.imgs.inv_slot_red,
344                };
345                let quality_col = get_quality_col(item.quality());
346
347                Image::new(self.imgs.pixel)
348                    .color(Some(shade_color(quality_col.alpha(0.7))))
349                    .w_h(ICON_BG_SIZE, ICON_BG_SIZE)
350                    .top_left_with_margins_on(list_message.widget_id, MESSAGE_VERTICAL_PADDING, 0.0)
351                    .set(state.ids.message_icon_bgs[i], ui);
352
353                Image::new(quality_col_image)
354                    .color(Some(shade_color(color::hsla(0.0, 0.0, 1.0, 1.0))))
355                    .wh_of(state.ids.message_icon_bgs[i])
356                    .middle_of(state.ids.message_icon_bgs[i])
357                    .set(state.ids.message_icon_frames[i], ui);
358
359                Image::new(animate_by_pulse(
360                    &self.item_imgs.img_ids_or_not_found_img(item.into()),
361                    self.pulse,
362                ))
363                .color(Some(shade_color(color::hsla(0.0, 0.0, 1.0, 1.0))))
364                .w_h(ICON_SIZE, ICON_SIZE)
365                .middle_of(state.ids.message_icon_bgs[i])
366                .with_item_tooltip(
367                    self.item_tooltip_manager,
368                    core::iter::once(item as &dyn ItemDesc),
369                    &None,
370                    &item_tooltip,
371                )
372                .set(state.ids.message_icons[i], ui);
373
374                let target_name = self
375                    .client
376                    .player_list()
377                    .get(taken_by)
378                    .map_or_else(
379                        || {
380                            self.client
381                                .state()
382                                .ecs()
383                                .entity_from_uid(*taken_by)
384                                .and_then(|entity| stats.get(entity).map(|e| e.name.clone()))
385                        },
386                        |info| Some(info.player_alias.clone()),
387                    )
388                    .unwrap_or_else(|| format!("<uid {}>", *taken_by));
389
390                let (user_gender, is_you) = match self.client.player_list().get(taken_by) {
391                    Some(player_info) => match player_info.character.as_ref() {
392                        Some(character_info) => (
393                            match character_info.gender {
394                                Some(common::comp::Gender::Feminine) => "she".to_string(),
395                                Some(common::comp::Gender::Masculine) => "he".to_string(),
396                                None => "??".to_string(),
397                            },
398                            self.client.uid().expect("Client doesn't have a Uid!!!") == *taken_by,
399                        ),
400                        None => ("??".to_string(), false),
401                    },
402                    None => ("??".to_string(), false),
403                };
404
405                let label = if is_you {
406                    self.localized_strings.get_msg_ctx(
407                            "hud-loot-pickup-msg-you",
408                            &i18n::fluent_args! {
409                                  "gender" => user_gender,
410                                  "amount" => amount.get(),
411                                  "item" => {
412                                      let (name, _) =
413                                          util::item_text(&item, self.localized_strings, self.item_i18n);
414                                      name
415                                  },
416                            },
417                        )
418                } else {
419                    self.localized_strings.get_msg_ctx(
420                            "hud-loot-pickup-msg",
421                            &i18n::fluent_args! {
422                                  "gender" => user_gender,
423                                  "actor" => target_name,
424                                  "amount" => amount.get(),
425                                  "item" => {
426                                      let (name, _) =
427                                          util::item_text(&item, self.localized_strings, self.item_i18n);
428                                      name
429                                  },
430                            },
431                        )
432                };
433
434                let label_font_size = 20;
435
436                Text::new(&label)
437                    .top_left_with_margins_on(
438                        list_message.widget_id,
439                        MESSAGE_VERTICAL_PADDING + 1.0,
440                        ICON_BG_SIZE + ICON_LABEL_SPACER,
441                    )
442                    .font_id(self.fonts.cyri.conrod_id)
443                    .font_size(self.fonts.cyri.scale(label_font_size))
444                    .color(shade_color(quality_col))
445                    .graphics_for(state.ids.message_icons[i])
446                    .and(|text| {
447                        let text_width = match text.get_x_dimension(ui) {
448                            Dimension::Absolute(x) => x,
449                            _ => f64::MAX,
450                        }
451                        .min(BOX_WIDTH - (ICON_BG_SIZE + ICON_LABEL_SPACER));
452                        text.w(text_width)
453                    })
454                    .set(state.ids.message_texts[i], ui);
455                Text::new(&label)
456                    .depth(1.0)
457                    .parent(list_message.widget_id)
458                    .x_y_relative_to(state.ids.message_texts[i], -1.0, -1.0)
459                    .wh_of(state.ids.message_texts[i])
460                    .font_id(self.fonts.cyri.conrod_id)
461                    .font_size(self.fonts.cyri.scale(label_font_size))
462                    .color(shade_color(color::rgba(0.0, 0.0, 0.0, 1.0)))
463                    .set(state.ids.message_text_shadows[i], ui);
464
465                let height = 2.0 * MESSAGE_VERTICAL_PADDING
466                    + ICON_BG_SIZE.max(
467                        1.0 + ui
468                            .rect_of(state.ids.message_texts[i])
469                            .map_or(0.0, |r| r.h() + label_font_size as f64 / 3.0),
470                        /* add to height since rect height does not account for lower parts of
471                         * letters */
472                    );
473
474                let rect = Rectangle::fill_with([BOX_WIDTH, height], color::TRANSPARENT);
475
476                list_message.set(rect, ui);
477            }
478        }
479    }
480}