veloren_voxygen/hud/
quest.rs

1use client::{Client, EcsEntity};
2use common::{
3    comp::{self, ItemKey},
4    rtsim,
5};
6use conrod_core::{
7    Borderable, Color, Colorable, Positionable, Sizeable, UiCell, Widget, WidgetCommon, color,
8    widget::{self, Button, Image, Rectangle, Scrollbar, Text},
9    widget_ids,
10};
11use i18n::Localization;
12use specs::WorldExt;
13use std::{
14    borrow::Cow,
15    time::{Duration, Instant},
16};
17
18use crate::{
19    GlobalState,
20    ui::{TooltipManager, fonts::Fonts},
21};
22use inline_tweak::*;
23
24use super::{
25    GameInput, Show, TEXT_COLOR, animate_by_pulse,
26    img_ids::{Imgs, ImgsRot},
27    item_imgs::ItemImgs,
28};
29
30pub struct State {
31    ids: Ids,
32    text_timer: Option<Instant>,
33    text_position: usize,
34    last_displayed_text: Option<String>, // New field to track the last message
35}
36
37widget_ids! {
38    pub struct Ids {
39        quest_close,
40        bg,
41        frame,
42        close,
43        title_align,
44        title,
45        text_align,
46        topics_align,
47        scrollbar,
48        intro_txt,
49        desc_txt_0,
50        ack_prompt,
51        quest_response_txt,
52        objective_text,
53        quest_responses_frames[],
54        quest_responses_btn[],
55        quest_responses_icons[],
56        quest_responses_amounts[],
57        quest_rewards_txts[],
58    }
59}
60
61#[derive(WidgetCommon)]
62pub struct Quest<'a> {
63    _show: &'a Show,
64    client: &'a Client,
65    _imgs: &'a Imgs,
66    fonts: &'a Fonts,
67    localized_strings: &'a Localization,
68    global_state: &'a GlobalState,
69    _rot_imgs: &'a ImgsRot,
70    _tooltip_manager: &'a mut TooltipManager,
71    item_imgs: &'a ItemImgs,
72    sender: EcsEntity,
73    dialogue: &'a rtsim::Dialogue<true>,
74    recv_time: Instant,
75    pulse: f32,
76
77    #[conrod(common_builder)]
78    common: widget::CommonBuilder,
79}
80
81impl<'a> Quest<'a> {
82    pub fn new(
83        _show: &'a Show,
84        client: &'a Client,
85        _imgs: &'a Imgs,
86        fonts: &'a Fonts,
87        localized_strings: &'a Localization,
88        global_state: &'a GlobalState,
89        _rot_imgs: &'a ImgsRot,
90        _tooltip_manager: &'a mut TooltipManager,
91        item_imgs: &'a ItemImgs,
92        sender: EcsEntity,
93        dialogue: &'a rtsim::Dialogue<true>,
94        recv_time: Instant,
95        pulse: f32,
96    ) -> Self {
97        Self {
98            _show,
99            client,
100            _imgs,
101            _rot_imgs,
102            fonts,
103            localized_strings,
104            global_state,
105            _tooltip_manager,
106            item_imgs,
107            sender,
108            dialogue,
109            recv_time,
110            pulse,
111            common: widget::CommonBuilder::default(),
112        }
113    }
114
115    fn update_text(&self, state: &mut State, ui: &mut UiCell, msg_text: &str) {
116        let now = Instant::now();
117
118        // Check if we have a new message
119        let is_new_message = state.text_position == 0
120            || state.text_position > msg_text.chars().count()
121            || state.last_displayed_text.as_deref() != Some(msg_text);
122
123        if is_new_message {
124            state.text_timer = Some(now);
125            state.text_position = 1; // Start displaying from the first character
126            state.last_displayed_text = Some(msg_text.to_string()); // Store the message
127        }
128
129        if state.text_timer.is_none() {
130            state.text_timer = Some(now);
131        }
132
133        if let Some(start_time) = state.text_timer
134            && now.duration_since(start_time) >= Duration::from_millis(10)
135            && state.text_position < msg_text.chars().count()
136        {
137            state.text_position += 1;
138            state.text_timer = Some(now);
139        }
140
141        let display_text: String = msg_text
142            .chars()
143            .take(state.text_position.min(msg_text.chars().count()))
144            .collect();
145
146        const MARGIN: f64 = 16.0;
147        Text::new(&display_text)
148            .top_left_with_margins_on(state.ids.text_align, MARGIN, MARGIN)
149            .w(429.0 - MARGIN * 2.0)
150            .h(200.0 - MARGIN * 2.0)
151            .font_id(self.fonts.cyri.conrod_id)
152            .font_size(self.fonts.cyri.scale(16))
153            .color(TEXT_COLOR)
154            .set(state.ids.desc_txt_0, ui);
155    }
156}
157
158pub enum Event {
159    Dialogue(EcsEntity, rtsim::Dialogue),
160    #[allow(dead_code)]
161    Close,
162}
163
164impl Widget for Quest<'_> {
165    type Event = Option<Event>;
166    type State = State;
167    type Style = ();
168
169    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
170        Self::State {
171            ids: Ids::new(id_gen),
172            text_timer: None,
173            text_position: 0,
174            last_displayed_text: None,
175        }
176    }
177
178    fn style(&self) -> Self::Style {}
179
180    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
181        let widget::UpdateArgs { state, ui, .. } = args;
182        let mut event = None;
183
184        // Window BG
185        // TODO: It would be nice to use `RoundedRectangle` here, but unfortunately it
186        // seems to not propagate scroll events properly!
187        Rectangle::fill_with([tweak!(130.0), tweak!(100.0)], color::TRANSPARENT)
188            .mid_bottom_with_margin_on(ui.window, 80.0)
189            .w_h(749.0, 234.0)
190            .set(state.ids.bg, ui);
191        // Window frame
192        Rectangle::fill_with([tweak!(130.0), tweak!(100.0)], color::TRANSPARENT)
193            .middle_of(state.ids.bg)
194            .w_h(749.0, tweak!(234.0))
195            .set(state.ids.frame, ui);
196
197        const BACKGROUND: Color = Color::Rgba(0.0, 0.0, 0.0, 0.85);
198
199        // Content Alignment
200        // Text Left
201        Rectangle::fill_with([tweak!(429.0), tweak!(200.0)], BACKGROUND)
202            .top_left_with_margins_on(state.ids.frame, tweak!(0.0), tweak!(0.0))
203            .scroll_kids_vertically()
204            .set(state.ids.text_align, ui);
205        // Topics Right
206        Rectangle::fill_with([tweak!(315.0), tweak!(200.0)], BACKGROUND)
207            .top_right_with_margins_on(state.ids.frame, tweak!(0.0), tweak!(2.0))
208            .scroll_kids_vertically()
209            .set(state.ids.topics_align, ui);
210        Scrollbar::y_axis(state.ids.topics_align)
211            .h(tweak!(169.0))
212            .top_right_with_margins(29.0, tweak!(4.0))
213            .thickness(tweak!(23.0))
214            .auto_hide(true)
215            .rgba(1.0, 1.0, 1.0, 0.2)
216            .set(state.ids.scrollbar, ui);
217
218        // Close Button
219        if Button::image(self._imgs.close_btn)
220            .w_h(24.0, 25.0)
221            .hover_image(self._imgs.close_btn_hover)
222            .press_image(self._imgs.close_btn_press)
223            .top_right_with_margins_on(state.ids.frame, 2.0, 4.0)
224            .set(state.ids.quest_close, ui)
225            .was_clicked()
226        {
227            event = Some(Event::Close);
228        }
229
230        if let rtsim::DialogueKind::Statement { .. } = &self.dialogue.kind {
231            let recv_time = self.recv_time.elapsed().as_secs_f32();
232            Text::new(&if let Some(key) = self
233                .global_state
234                .settings
235                .controls
236                .get_binding(GameInput::Interact)
237            {
238                self.localized_strings.get_msg_ctx(
239                    "hud-dialogue-ack",
240                    &i18n::fluent_args! { "key" => key.display_string() },
241                )
242            } else {
243                Cow::Borrowed("")
244            })
245            .bottom_right_with_margins_on(state.ids.text_align, 12.0, 12.0)
246            .font_id(self.fonts.cyri.conrod_id)
247            .font_size(self.fonts.cyri.scale(12))
248            .color(Color::Rgba(
249                1.0,
250                1.0,
251                1.0,
252                (0.6 + (recv_time * tweak!(5.0)).sin() * 0.4) * (recv_time - 1.0).clamp(0.0, 1.0),
253            ))
254            .set(state.ids.ack_prompt, ui);
255        }
256
257        // Define type of quest to change introduction text
258        let msg_text = self
259            .dialogue
260            .message()
261            .map(|msg| self.localized_strings.get_content(msg));
262
263        if let Some(msg_text) = msg_text {
264            state.update(|s| {
265                self.update_text(s, ui, &msg_text);
266            });
267        }
268
269        if let rtsim::DialogueKind::Question { responses, tag, .. } = &self.dialogue.kind {
270            if state.ids.quest_responses_frames.len() < responses.len() {
271                state.update(|s| {
272                    s.ids
273                        .quest_responses_frames
274                        .resize(responses.len(), &mut ui.widget_id_generator())
275                })
276            };
277            if state.ids.quest_responses_icons.len() < responses.len() {
278                state.update(|s| {
279                    s.ids
280                        .quest_responses_icons
281                        .resize(responses.len(), &mut ui.widget_id_generator())
282                })
283            };
284            if state.ids.quest_responses_amounts.len() < responses.len() {
285                state.update(|s| {
286                    s.ids
287                        .quest_responses_amounts
288                        .resize(responses.len(), &mut ui.widget_id_generator())
289                })
290            };
291            if state.ids.quest_rewards_txts.len() < responses.len() {
292                state.update(|s| {
293                    s.ids
294                        .quest_rewards_txts
295                        .resize(responses.len(), &mut ui.widget_id_generator())
296                })
297            };
298            if state.ids.quest_responses_btn.len() < responses.len() {
299                state.update(|s| {
300                    s.ids
301                        .quest_responses_btn
302                        .resize(responses.len(), &mut ui.widget_id_generator())
303                })
304            };
305
306            for (i, (response_id, response)) in responses.iter().enumerate() {
307                // Determine whether all requirements for sending the response are met
308                let is_valid = if let Some((item, amount)) = &response.given_item {
309                    self.client
310                        .state()
311                        .ecs()
312                        .read_storage::<comp::Inventory>()
313                        .get(self.client.entity())
314                        .is_some_and(|inv| inv.item_count(item) >= *amount as u64)
315                } else {
316                    true
317                };
318
319                let frame = Button::new()
320                    .border_color(color::TRANSPARENT)
321                    .color(Color::Rgba(1.0, 1.0, 1.0, 0.0))
322                    .hover_color(if is_valid {
323                        Color::Rgba(1.0, 1.0, 1.0, 0.05)
324                    } else {
325                        Color::Rgba(1.0, 0.5, 0.5, 0.05)
326                    })
327                    .press_color(Color::Rgba(1.0, 1.0, 1.0, 0.1))
328                    .parent(state.ids.topics_align)
329                    .w_h(286.0, 30.0);
330                let frame = if i == 0 {
331                    frame.top_left_with_margins_on(state.ids.topics_align, tweak!(0.0), tweak!(0.0))
332                } else {
333                    frame.down_from(state.ids.quest_responses_frames[i - 1], 0.0)
334                };
335                if frame
336                    .set(state.ids.quest_responses_frames[i], ui)
337                    .was_clicked()
338                {
339                    event = Some(Event::Dialogue(self.sender, rtsim::Dialogue {
340                        id: self.dialogue.id,
341                        kind: rtsim::DialogueKind::Response {
342                            tag: *tag,
343                            response: response.clone(),
344                            response_id: *response_id,
345                        },
346                    }));
347                }
348
349                // Response text
350                Text::new(&self.localized_strings.get_content(&response.msg))
351                    .middle_of(state.ids.quest_responses_frames[i])
352                    .graphics_for(state.ids.quest_responses_frames[i])
353                    .font_id(self.fonts.cyri.conrod_id)
354                    .color(Color::Rgba(1.0, 1.0, 1.0, if is_valid { 1.0 } else { 0.3 }))
355                    .font_size(self.fonts.cyri.scale(tweak!(14)))
356                    .set(state.ids.quest_rewards_txts[i], ui);
357
358                // Item image
359                if let Some((item, amount)) = &response.given_item {
360                    Image::new(animate_by_pulse(
361                        &self
362                            .item_imgs
363                            .img_ids_or_not_found_img(ItemKey::from(&**item)),
364                        self.pulse,
365                    ))
366                    .mid_left_with_margin_on(state.ids.quest_responses_frames[i], 8.0)
367                    .w_h(20.0, 20.0)
368                    .graphics_for(state.ids.quest_responses_frames[i])
369                    .set(state.ids.quest_responses_icons[i], ui);
370
371                    Text::new(&format!("x{amount}"))
372                        .mid_left_with_margin_on(state.ids.quest_responses_icons[i], tweak!(24.0))
373                        .font_id(self.fonts.cyri.conrod_id)
374                        .font_size(self.fonts.cyri.scale(12))
375                        .color(if is_valid {
376                            TEXT_COLOR
377                        } else {
378                            // Not enough present!
379                            Color::Rgba(1.0, 0.2, 0.2, 0.6 + (self.pulse * 8.0).sin() * 0.4)
380                        })
381                        .wrap_by_word()
382                        .set(state.ids.quest_responses_amounts[i], ui);
383                }
384            }
385        }
386
387        event
388    }
389}