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 current: Option<(Hint, Option<Achievement>, Duration)>,
169 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 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 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 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(¤t.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}