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