veloren_voxygen/hud/
tutorial.rs

1use crate::{
2    GlobalState, Settings,
3    session::interactable::{EntityInteraction, Interactable},
4    ui::{ImageFrame, Tooltip, TooltipManager, Tooltipable, fonts::Fonts},
5};
6use client::Client;
7use common::{
8    DamageSource,
9    comp::{self, Vel},
10    resources::TimeOfDay,
11    terrain::SiteKindMeta,
12};
13use conrod_core::{
14    Color, Colorable, Positionable, Sizeable, Widget, WidgetCommon, color,
15    widget::{self, Button, Image, Rectangle, RoundedRectangle, Scrollbar, Text},
16    widget_ids,
17};
18use i18n::Localization;
19use inline_tweak::*;
20use serde::{Deserialize, Serialize};
21use specs::WorldExt;
22use std::{borrow::Cow, time::Duration};
23use vek::*;
24
25use super::{
26    GameInput, Outcome, Show, TEXT_COLOR, UserNotification,
27    img_ids::{Imgs, ImgsRot},
28    item_imgs::ItemImgs,
29};
30
31#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum Hint {
33    Move,
34    Jump,
35    OpenInventory,
36    FallDamage,
37    OpenGlider,
38    Glider,
39    StallGlider,
40    Roll,
41    Attacked,
42    Unwield,
43    Campfire,
44    Waypoint,
45    OpenDiary,
46    FullInventory,
47    RespawnDurability,
48    RecipeAvailable,
49    EnergyLow,
50    Chat,
51    Sneak,
52    Lantern,
53    Zoom,
54    FirstPerson,
55    Swim,
56    OpenMap,
57    UseItem,
58    Crafting,
59}
60
61#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub enum Achievement {
63    Moved,
64    Jumped,
65    OpenInventory,
66    OpenGlider,
67    StallGlider,
68    Rolled,
69    Wield,
70    Unwield,
71    FindCampfire,
72    SetWaypoint,
73    OpenDiary,
74    FullInventory,
75    Respawned,
76    RecipeAvailable,
77    OpenCrafting,
78    EnergyLow,
79    ReceivedChatMsg,
80    NearEnemies,
81    InDark,
82    UsedLantern,
83    Swim,
84    Zoom,
85    OpenMap,
86}
87
88impl Hint {
89    const FADE_TIME: f32 = 5.0;
90
91    fn get_msg<'a>(&self, settings: &Settings, i18n: &'a Localization) -> Cow<'a, str> {
92        let get_key = |key| {
93            settings
94                .controls
95                .get_binding(key)
96                .map(|key| key.display_string())
97                .unwrap_or_else(|| "<unbound>".to_string())
98        };
99        let key = &format!("tutorial-{self:?}");
100        match self {
101            Self::Move => i18n.get_msg_ctx(key, &i18n::fluent_args! {
102                "w" => get_key(GameInput::MoveForward),
103                "a" => get_key(GameInput::MoveLeft),
104                "s" => get_key(GameInput::MoveBack),
105                "d" => get_key(GameInput::MoveRight),
106            }),
107            Self::Jump => i18n.get_msg_ctx(key, &i18n::fluent_args! {
108                "key" => get_key(GameInput::Jump),
109            }),
110            Self::OpenInventory => i18n.get_msg_ctx(key, &i18n::fluent_args! {
111                "key" => get_key(GameInput::Inventory),
112            }),
113            Self::FallDamage | Self::OpenGlider => i18n.get_msg_ctx(key, &i18n::fluent_args! {
114                "key" => get_key(GameInput::Glide),
115            }),
116            Self::Roll => i18n.get_msg_ctx(key, &i18n::fluent_args! {
117                "key" => get_key(GameInput::Roll),
118            }),
119            Self::Attacked => i18n.get_msg_ctx(key, &i18n::fluent_args! {
120                "key" => get_key(GameInput::Primary),
121            }),
122            Self::Unwield => i18n.get_msg_ctx(key, &i18n::fluent_args! {
123                "key" => get_key(GameInput::ToggleWield),
124            }),
125            Self::Campfire => i18n.get_msg_ctx(key, &i18n::fluent_args! {
126                "key" => get_key(GameInput::Sit),
127            }),
128            Self::OpenDiary => i18n.get_msg_ctx(key, &i18n::fluent_args! {
129                "key" => get_key(GameInput::Diary),
130            }),
131            Self::RecipeAvailable => i18n.get_msg_ctx(key, &i18n::fluent_args! {
132                "key" => get_key(GameInput::Crafting),
133            }),
134            Self::Chat => i18n.get_msg_ctx(key, &i18n::fluent_args! {
135                "key" => get_key(GameInput::Chat),
136            }),
137            Self::Sneak => i18n.get_msg_ctx(key, &i18n::fluent_args! {
138                "key" => get_key(GameInput::Sneak),
139            }),
140            Self::Lantern => i18n.get_msg_ctx(key, &i18n::fluent_args! {
141                "key" => get_key(GameInput::ToggleLantern),
142            }),
143            Self::Zoom => i18n.get_msg_ctx(key, &i18n::fluent_args! {
144                "in" => get_key(GameInput::ZoomIn),
145                "out" => get_key(GameInput::ZoomOut),
146            }),
147            Self::Swim => i18n.get_msg_ctx(key, &i18n::fluent_args! {
148                "down" => get_key(GameInput::SwimDown),
149                "up" => get_key(GameInput::SwimUp),
150            }),
151            Self::OpenMap => i18n.get_msg_ctx(key, &i18n::fluent_args! {
152                "key" => get_key(GameInput::Map),
153            }),
154            _ => i18n.get_msg(key),
155        }
156    }
157}
158
159impl Achievement {
160    fn get_msg<'a>(&self, _settings: &Settings, i18n: &'a Localization) -> Cow<'a, str> {
161        i18n.get_msg(&format!("achievement-{self:?}"))
162    }
163}
164
165#[derive(Clone, Debug, Serialize, Deserialize)]
166pub struct TutorialState {
167    // (_, time_since_active)
168    current: Option<(Hint, Option<Achievement>, Duration)>,
169    // (_, cancel if achieved, time until display)
170    pending: Vec<(Hint, Option<Achievement>, Duration)>,
171
172    time_ingame: Duration,
173    goals: Vec<Achievement>,
174    done: Vec<Achievement>,
175    disabled: bool,
176}
177
178impl Default for TutorialState {
179    fn default() -> Self {
180        let mut this = Self {
181            current: None,
182            pending: Vec::new(),
183            goals: Vec::new(),
184            done: Default::default(),
185            time_ingame: Duration::ZERO,
186            disabled: false,
187        };
188        this.add_hinted_goal(Hint::Move, Achievement::Moved, Duration::from_secs(10));
189        this.show_hint(Hint::Zoom, Duration::from_mins(3));
190        this
191    }
192}
193
194impl TutorialState {
195    fn update(&mut self, dt: Duration) {
196        self.time_ingame += dt;
197        self.current.take_if(|(_, a, dur)| {
198            if a.map_or(false, |a| self.done.contains(&a)) {
199                *dur = (*dur).max(Duration::from_secs_f32(Hint::FADE_TIME * 2.0 - 1.0));
200            }
201            *dur > Duration::from_secs(12)
202        });
203        for (_, _, dur) in &mut self.pending {
204            *dur = dur.saturating_sub(dt);
205        }
206        self.pending.retain(|(hint, achievement, dur)| {
207            if dur.is_zero() && self.current.is_none() {
208                self.current = Some((*hint, *achievement, Duration::ZERO));
209                false
210            } else if let Some(a) = achievement
211                && self.done.contains(a)
212            {
213                false
214            } else {
215                true
216            }
217        });
218    }
219
220    fn done(&self, achievement: Achievement) -> bool { self.done.contains(&achievement) }
221
222    fn earn_achievement(&mut self, achievement: Achievement) -> bool {
223        if !self.done.contains(&achievement) {
224            self.done.push(achievement);
225            self.goals.retain(|a| a != &achievement);
226            self.pending.retain(|(_, a, _)| a != &Some(achievement));
227            true
228        } else {
229            false
230        }
231    }
232
233    fn add_goal(&mut self, achievement: Achievement) {
234        if !self.done.contains(&achievement) && !self.goals.contains(&achievement) {
235            self.goals.push(achievement);
236        }
237    }
238
239    fn add_hinted_goal(&mut self, hint: Hint, achievement: Achievement, timeout: Duration) {
240        if self.pending.iter().all(|(h, _, _)| h != &hint) && !self.done(achievement) {
241            self.add_goal(achievement);
242            self.pending.push((hint, Some(achievement), timeout));
243        }
244    }
245
246    fn show_hint(&mut self, hint: Hint, timeout: Duration) {
247        self.pending.push((hint, None, timeout));
248    }
249
250    pub(crate) fn event_tick(&mut self, client: &Client) {
251        if let Some(comp::CharacterState::Glide(glide)) = client.current::<comp::CharacterState>()
252            && glide.ori.look_dir().z > 0.4
253            && let Some(vel) = client.current::<Vel>()
254            && vel.0.z < -vel.0.xy().magnitude()
255            && self.earn_achievement(Achievement::StallGlider)
256        {
257            self.show_hint(Hint::StallGlider, Duration::ZERO);
258        }
259
260        if let Some(cs) = client.current::<comp::CharacterState>() {
261            if cs.is_wield() && self.earn_achievement(Achievement::Wield) {
262                self.add_hinted_goal(Hint::Unwield, Achievement::Unwield, Duration::from_mins(2));
263            }
264
265            if !cs.is_wield() && self.done(Achievement::Wield) {
266                self.earn_achievement(Achievement::Unwield);
267            }
268        }
269
270        if let Some(ps) = client.current::<comp::PhysicsState>()
271            && ps.in_liquid().is_some()
272            && self.earn_achievement(Achievement::Swim)
273        {
274            self.show_hint(Hint::Swim, Duration::from_secs(3));
275        }
276
277        if let Some(inv) = client.current::<comp::Inventory>()
278            && inv.free_slots() == 0
279            && self.earn_achievement(Achievement::FullInventory)
280        {
281            self.show_hint(Hint::FullInventory, Duration::from_secs(2));
282        }
283
284        if let Some(energy) = client.current::<comp::Energy>()
285            && energy.fraction() < 0.25
286            && self.earn_achievement(Achievement::EnergyLow)
287        {
288            self.show_hint(Hint::EnergyLow, Duration::ZERO);
289        }
290
291        if !self.done(Achievement::RecipeAvailable) && !client.available_recipes().is_empty() {
292            self.earn_achievement(Achievement::RecipeAvailable);
293            self.add_hinted_goal(
294                Hint::RecipeAvailable,
295                Achievement::OpenCrafting,
296                Duration::from_secs(1),
297            );
298        }
299
300        if self.time_ingame > Duration::from_mins(10)
301            && let Some(chunk) = client.current_chunk()
302            && let Some(pos) = client.current::<comp::Pos>()
303            && let in_cave = pos.0.z < chunk.meta().alt() - 20.0
304            && let near_enemies = matches!(
305                chunk.meta().site(),
306                Some(SiteKindMeta::Dungeon(_) | SiteKindMeta::Cave)
307            )
308            && (in_cave || near_enemies)
309            && self.earn_achievement(Achievement::NearEnemies)
310        {
311            self.show_hint(Hint::Sneak, Duration::ZERO);
312        }
313
314        if self.time_ingame > Duration::from_mins(3)
315            && client
316                .state()
317                .ecs()
318                .read_resource::<TimeOfDay>()
319                .day_period()
320                .is_dark()
321            && self.earn_achievement(Achievement::InDark)
322        {
323            self.add_hinted_goal(
324                Hint::Lantern,
325                Achievement::UsedLantern,
326                Duration::from_secs(10),
327            );
328        }
329    }
330
331    pub(crate) fn event_move(&mut self) {
332        self.earn_achievement(Achievement::Moved);
333        self.add_hinted_goal(Hint::Jump, Achievement::Jumped, Duration::from_secs(15));
334    }
335
336    pub(crate) fn event_jump(&mut self) {
337        self.earn_achievement(Achievement::Jumped);
338        self.add_hinted_goal(Hint::Roll, Achievement::Rolled, Duration::from_secs(30));
339    }
340
341    pub(crate) fn event_roll(&mut self) {
342        self.earn_achievement(Achievement::Rolled);
343        self.add_hinted_goal(
344            Hint::OpenGlider,
345            Achievement::OpenGlider,
346            Duration::from_mins(2),
347        );
348    }
349
350    pub(crate) fn event_collect(&mut self) {
351        self.add_hinted_goal(
352            Hint::OpenInventory,
353            Achievement::OpenInventory,
354            Duration::from_secs(1),
355        );
356    }
357
358    pub(crate) fn event_respawn(&mut self) {
359        if self.earn_achievement(Achievement::Respawned) {
360            self.show_hint(Hint::RespawnDurability, Duration::from_secs(5));
361        }
362    }
363
364    pub(crate) fn event_open_inventory(&mut self) {
365        if self.earn_achievement(Achievement::OpenInventory) {
366            self.show_hint(Hint::UseItem, Duration::from_secs(1));
367        }
368    }
369
370    pub(crate) fn event_open_diary(&mut self) { self.earn_achievement(Achievement::OpenDiary); }
371
372    pub(crate) fn event_open_crafting(&mut self) {
373        if self.earn_achievement(Achievement::OpenCrafting) {
374            self.show_hint(Hint::Crafting, Duration::from_secs(1));
375        }
376    }
377
378    pub(crate) fn event_open_map(&mut self) { self.earn_achievement(Achievement::OpenMap); }
379
380    pub(crate) fn event_map_marker(&mut self) {
381        self.add_hinted_goal(Hint::OpenMap, Achievement::OpenMap, Duration::from_secs(1));
382    }
383
384    pub(crate) fn event_lantern(&mut self) { self.earn_achievement(Achievement::UsedLantern); }
385
386    pub(crate) fn event_zoom(&mut self, delta: f32) {
387        if delta < 0.0
388            && self.time_ingame > Duration::from_mins(15)
389            && self.earn_achievement(Achievement::Zoom)
390        {
391            self.show_hint(Hint::FirstPerson, Duration::from_secs(2));
392        }
393    }
394
395    pub(crate) fn event_outcome(&mut self, client: &Client, outcome: &Outcome) {
396        match outcome {
397            Outcome::HealthChange { info, .. }
398                if Some(info.target) == client.uid()
399                    && info.cause == Some(DamageSource::Falling) =>
400            {
401                self.add_hinted_goal(
402                    Hint::FallDamage,
403                    Achievement::OpenGlider,
404                    Duration::from_secs(1),
405                );
406            },
407            Outcome::HealthChange { info, .. }
408                if Some(info.target) == client.uid()
409                    && !matches!(info.cause, Some(DamageSource::Falling))
410                    && info.amount < 0.0 =>
411            {
412                self.add_hinted_goal(Hint::Attacked, Achievement::Wield, Duration::ZERO);
413            },
414            Outcome::SkillPointGain { uid, .. } if Some(*uid) == client.uid() => {
415                self.add_hinted_goal(
416                    Hint::OpenDiary,
417                    Achievement::OpenDiary,
418                    Duration::from_secs(3),
419                );
420            },
421            _ => {},
422        }
423    }
424
425    pub(crate) fn event_open_glider(&mut self) {
426        if self.earn_achievement(Achievement::OpenGlider) {
427            self.show_hint(Hint::Glider, Duration::from_secs(1));
428        }
429    }
430
431    pub(crate) fn event_find_interactable(&mut self, inter: &Interactable) {
432        #[allow(clippy::single_match)]
433        match inter {
434            Interactable::Entity {
435                interaction: EntityInteraction::CampfireSit,
436                ..
437            } => {
438                if self.earn_achievement(Achievement::FindCampfire) {
439                    self.show_hint(Hint::Campfire, Duration::from_secs(1));
440                }
441            },
442            _ => {},
443        }
444    }
445
446    pub(crate) fn event_notification(&mut self, notif: &UserNotification) {
447        #[allow(clippy::single_match)]
448        match notif {
449            UserNotification::WaypointUpdated => {
450                if self.earn_achievement(Achievement::SetWaypoint) {
451                    self.show_hint(Hint::Waypoint, Duration::from_secs(1));
452                }
453            },
454        }
455    }
456
457    pub(crate) fn event_chat_msg(&mut self, msg: &comp::ChatMsg) {
458        if msg.chat_type.is_player_msg() && self.earn_achievement(Achievement::ReceivedChatMsg) {
459            self.show_hint(Hint::Chat, Duration::from_secs(2));
460        }
461    }
462}
463
464pub struct State {
465    ids: Ids,
466}
467
468widget_ids! {
469    pub struct Ids {
470        bg,
471        text,
472        close_btn,
473        old_frame,
474        old_scrollbar,
475        old_bg[],
476        old_text[],
477        old_icon[],
478    }
479}
480
481#[derive(WidgetCommon)]
482pub struct Tutorial<'a> {
483    _show: &'a Show,
484    client: &'a Client,
485    imgs: &'a Imgs,
486    fonts: &'a Fonts,
487    localized_strings: &'a Localization,
488    global_state: &'a mut GlobalState,
489    rot_imgs: &'a ImgsRot,
490    tooltip_manager: &'a mut TooltipManager,
491    _item_imgs: &'a ItemImgs,
492    pulse: f32,
493    dt: Duration,
494    esc_menu: bool,
495
496    #[conrod(common_builder)]
497    common: widget::CommonBuilder,
498}
499
500const MARGIN: f64 = 16.0;
501
502impl<'a> Tutorial<'a> {
503    pub fn new(
504        _show: &'a Show,
505        client: &'a Client,
506        imgs: &'a Imgs,
507        fonts: &'a Fonts,
508        localized_strings: &'a Localization,
509        global_state: &'a mut GlobalState,
510        rot_imgs: &'a ImgsRot,
511        tooltip_manager: &'a mut TooltipManager,
512        _item_imgs: &'a ItemImgs,
513        pulse: f32,
514        dt: Duration,
515        esc_menu: bool,
516    ) -> Self {
517        Self {
518            _show,
519            client,
520            imgs,
521            rot_imgs,
522            fonts,
523            localized_strings,
524            global_state,
525            tooltip_manager,
526            _item_imgs,
527            pulse,
528            dt,
529            esc_menu,
530            common: widget::CommonBuilder::default(),
531        }
532    }
533}
534
535impl Widget for Tutorial<'_> {
536    type Event = ();
537    type State = State;
538    type Style = ();
539
540    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
541        Self::State {
542            ids: Ids::new(id_gen),
543        }
544    }
545
546    fn style(&self) -> Self::Style {}
547
548    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
549        let widget::UpdateArgs { state, ui, .. } = args;
550
551        // Don't render anything if the tutorial was disabled
552        if self.global_state.profile.tutorial.disabled {
553            return;
554        }
555
556        self.global_state.profile.tutorial.update(self.dt);
557        self.global_state.profile.tutorial.event_tick(self.client);
558
559        let mut old = Vec::new();
560        // TODO: Decide on whether viewing achievements is desirable after play-testing
561        if tweak!(false) && self.esc_menu {
562            old.extend(
563                self.global_state
564                    .profile
565                    .tutorial
566                    .goals
567                    .iter()
568                    .rev()
569                    .map(|n| (*n, false)),
570            );
571            old.extend(
572                self.global_state
573                    .profile
574                    .tutorial
575                    .done
576                    .iter()
577                    .rev()
578                    .map(|n| (*n, true)),
579            );
580        }
581
582        if state.ids.old_bg.len() < old.len() {
583            state.update(|s| {
584                s.ids
585                    .old_bg
586                    .resize(old.len(), &mut ui.widget_id_generator());
587                s.ids
588                    .old_text
589                    .resize(old.len(), &mut ui.widget_id_generator());
590                s.ids
591                    .old_icon
592                    .resize(old.len(), &mut ui.widget_id_generator());
593            })
594        }
595
596        if !old.is_empty() {
597            Rectangle::fill_with([tweak!(130.0), tweak!(100.0)], color::TRANSPARENT)
598                .mid_right_with_margin_on(ui.window, 0.0)
599                .scroll_kids_vertically()
600                .w_h(800.0, 350.0)
601                .set(state.ids.old_frame, ui);
602            Scrollbar::y_axis(state.ids.old_frame)
603                .thickness(5.0)
604                .auto_hide(true)
605                .rgba(1.0, 1.0, 1.0, 0.2)
606                .set(state.ids.old_scrollbar, ui);
607        }
608
609        const BACKGROUND: Color = Color::Rgba(0.13, 0.13, 0.13, 0.85);
610
611        for (i, (node, is_done)) in old.iter().copied().enumerate() {
612            let bg = RoundedRectangle::fill_with([tweak!(230.0), tweak!(100.0)], 20.0, BACKGROUND);
613            let bg = if i == 0 {
614                bg.top_left_with_margins_on(state.ids.old_frame, tweak!(8.0), tweak!(8.0))
615            } else {
616                bg.down_from(state.ids.old_bg[i - 1], 8.0)
617            };
618            bg.w_h(800.0, 52.0)
619                .parent(state.ids.old_frame)
620                .set(state.ids.old_bg[i], ui);
621
622            Image::new(if is_done {
623                self.imgs.check_checked
624            } else {
625                self.imgs.check
626            })
627            .mid_left_with_margin_on(state.ids.old_bg[i], MARGIN)
628            .w_h(24.0, 24.0)
629            .set(state.ids.old_icon[i], ui);
630
631            Text::new(&node.get_msg(&self.global_state.settings, self.localized_strings))
632                .mid_left_with_margin_on(state.ids.old_bg[i], 24.0 + MARGIN * 2.0)
633                .font_id(self.fonts.cyri.conrod_id)
634                .font_size(self.fonts.cyri.scale(16))
635                .color(TEXT_COLOR)
636                .set(state.ids.old_text[i], ui);
637        }
638
639        if let Some((current, _, anim)) = &mut self.global_state.profile.tutorial.current {
640            *anim += self.dt;
641
642            let anim_alpha = ((Hint::FADE_TIME - (anim.as_secs_f32() - Hint::FADE_TIME).abs())
643                * 3.0)
644                .clamped(0.0, 1.0);
645            let anim_movement = anim_alpha * (1.0 - (self.pulse * 3.0).sin().powi(14) * 0.25);
646
647            let close_tooltip = Tooltip::new({
648                // Edge images [t, b, r, l]
649                // Corner images [tr, tl, br, bl]
650                let edge = &self.rot_imgs.tt_side;
651                let corner = &self.rot_imgs.tt_corner;
652                ImageFrame::new(
653                    [edge.cw180, edge.none, edge.cw270, edge.cw90],
654                    [corner.none, corner.cw270, corner.cw90, corner.cw180],
655                    Color::Rgba(0.08, 0.07, 0.04, 1.0),
656                    5.0,
657                )
658            })
659            .title_font_size(self.fonts.cyri.scale(15))
660            .parent(ui.window)
661            .desc_font_size(self.fonts.cyri.scale(12))
662            .font_id(self.fonts.cyri.conrod_id)
663            .desc_text_color(TEXT_COLOR);
664
665            RoundedRectangle::fill_with(
666                [130.0, 100.0],
667                20.0,
668                BACKGROUND.with_alpha(0.85 * anim_alpha),
669            )
670            .mid_top_with_margin_on(ui.window, 80.0 * anim_movement.sqrt() as f64)
671            .w_h(800.0, 52.0)
672            .set(state.ids.bg, ui);
673
674            if Button::image(self.imgs.disable_bell_btn)
675                .mid_right_with_margin_on(state.ids.bg, MARGIN)
676                .w_h(20.0, 20.0)
677                .image_color_with_feedback(Color::Rgba(1.0, 1.0, 1.0, 0.5 * anim_alpha))
678                .with_tooltip(
679                    self.tooltip_manager,
680                    &self.localized_strings.get_msg("hud-tutorial-disable"),
681                    "",
682                    &close_tooltip,
683                    TEXT_COLOR,
684                )
685                .set(state.ids.close_btn, ui)
686                .was_clicked()
687            {
688                self.global_state.profile.tutorial.disabled = true;
689            }
690
691            Text::new(&current.get_msg(&self.global_state.settings, self.localized_strings))
692                .mid_left_with_margin_on(state.ids.bg, MARGIN)
693                .font_id(self.fonts.cyri.conrod_id)
694                .font_size(self.fonts.cyri.scale(16))
695                .color(TEXT_COLOR.with_alpha(anim_alpha))
696                .set(state.ids.text, ui);
697        }
698    }
699}