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