Skip to main content

blizz_ui/components/mascot/
component.rs

1use std::io::Write;
2
3use rand::Rng;
4
5use super::bobbing;
6use super::frames::{MascotFrames, queue_frame, queue_frame_owned};
7use super::fx;
8use crate::layout::{centered_block_origin, top_two_thirds};
9use crate::{Component, Renderer};
10
11/// Wizard mascot with entrance/dissolve progress, floating bobbing, and cached distance map.
12///
13/// Owns all per-frame effect state (tick counters and dissolve tile grid) so
14/// the wizard loop doesn't have to manage them externally.
15#[derive(Clone, Debug)]
16pub struct MascotComponent {
17  pub frames: MascotFrames,
18  pub entrance: f64,
19  pub dissolve: f64,
20  pub floating: bool,
21  distance_map: fx::DistanceMap,
22  entrance_tick: usize,
23  dissolve_tick: usize,
24  dissolve_state: Option<fx::DissolveState>,
25}
26
27impl MascotComponent {
28  pub fn blizz() -> Self {
29    let frames = MascotFrames::blizz();
30    let distance_map = fx::build_distance_map(frames.lines());
31    Self {
32      frames,
33      entrance: 0.0,
34      dissolve: 0.0,
35      floating: false,
36      distance_map,
37      entrance_tick: 0,
38      dissolve_tick: 0,
39      dissolve_state: None,
40    }
41  }
42
43  /// Advance entrance and dissolve tick counters for the current frame.
44  pub fn advance_effect_ticks(&mut self) {
45    if self.entrance < 1.0 && self.dissolve <= 0.0 {
46      self.entrance_tick += 1;
47    }
48    if self.dissolve > 0.0 && self.dissolve < 1.0 {
49      self.dissolve_tick += 1;
50    }
51  }
52
53  /// Build and store the dissolve tile grid. Returns `total_ticks` for
54  /// the caller to track progress against.
55  pub fn init_dissolve(&mut self, rng: &mut Box<dyn Rng>) -> usize {
56    let state = fx::build_dissolve_state(self.frames.lines(), rng);
57    let total = state.total_ticks;
58    self.dissolve_state = Some(state);
59    total
60  }
61}
62
63#[cfg(not(tarpaulin_include))]
64impl MascotComponent {
65  fn compute_origin(
66    &self,
67    terminal_size: crate::layout::Size,
68    elapsed: std::time::Duration,
69  ) -> crate::layout::Position {
70    let region = top_two_thirds(terminal_size);
71    if self.floating {
72      let bob = bobbing::bobbing_frame(elapsed);
73      bobbing::bobbing_origin(region, self.frames.size(), bob.row_offset)
74    } else {
75      centered_block_origin(region, self.frames.size())
76    }
77  }
78
79  fn render_frame<W: Write, R: Rng>(
80    &self,
81    writer: &mut W,
82    origin: crate::layout::Position,
83    rng: &mut R,
84  ) -> std::io::Result<()> {
85    let lines = self.frames.lines();
86
87    if self.dissolve > 0.0
88      && let Some(state) = &self.dissolve_state
89    {
90      let frame = fx::dissolve_frame(lines, state, self.dissolve_tick, rng);
91      return queue_frame_owned(writer, origin, &frame);
92    }
93
94    if self.entrance < 1.0 {
95      let frame = fx::entrance_frame(lines, &self.distance_map, self.entrance_tick, rng);
96      return queue_frame_owned(writer, origin, &frame);
97    }
98
99    queue_frame(writer, origin, lines)
100  }
101}
102
103#[cfg(not(tarpaulin_include))]
104impl Component for MascotComponent {
105  fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> std::io::Result<u16> {
106    if (self.entrance <= 0.0 && self.dissolve <= 0.0) || self.dissolve >= 1.0 {
107      return Ok(0);
108    }
109    let terminal_size = renderer.ctx().terminal_size;
110    let elapsed = renderer.ctx().elapsed;
111    renderer.with_panel(|writer, _panel, rng| {
112      let origin = self.compute_origin(terminal_size, elapsed);
113      self.render_frame(writer, origin, rng)?;
114      Ok(0)
115    })
116  }
117}
118
119#[cfg(test)]
120mod tests {
121  use super::*;
122
123  #[test]
124  fn blizz_starts_with_zero_progress() {
125    let m = MascotComponent::blizz();
126    assert_eq!(m.entrance, 0.0);
127    assert_eq!(m.dissolve, 0.0);
128    assert!(!m.floating);
129    assert!(m.dissolve_state.is_none());
130  }
131
132  #[test]
133  fn advance_effect_ticks_increments_entrance_when_entering() {
134    let mut m = MascotComponent::blizz();
135    m.entrance = 0.5;
136    m.advance_effect_ticks();
137    assert_eq!(m.entrance_tick, 1);
138    assert_eq!(m.dissolve_tick, 0);
139  }
140
141  #[test]
142  fn advance_effect_ticks_increments_dissolve_when_dissolving() {
143    let mut m = MascotComponent::blizz();
144    m.entrance = 1.0;
145    m.dissolve = 0.5;
146    m.advance_effect_ticks();
147    assert_eq!(
148      m.entrance_tick, 0,
149      "entrance tick should not advance once entrance is done"
150    );
151    assert_eq!(m.dissolve_tick, 1);
152  }
153
154  #[test]
155  fn advance_effect_ticks_noop_when_idle() {
156    let mut m = MascotComponent::blizz();
157    m.entrance = 1.0;
158    m.dissolve = 0.0;
159    m.advance_effect_ticks();
160    assert_eq!(m.entrance_tick, 0);
161    assert_eq!(m.dissolve_tick, 0);
162  }
163
164  #[test]
165  fn init_dissolve_stores_state_and_returns_total_ticks() {
166    let mut m = MascotComponent::blizz();
167    let mut rng: Box<dyn rand::Rng> = Box::new(rand::rng());
168    let total = m.init_dissolve(&mut rng);
169    assert!(total > 0);
170    assert!(m.dissolve_state.is_some());
171  }
172}