Skip to content

Commit ec3e571

Browse files
unhappychoiceclaude
andcommitted
feat: add typing animation with colored messages and skip functionality
- Add typing animation screen with typewriter effect for session results - Implement line-by-line colored messages for all 51 ranking titles - Add humorous IT industry messages for Beginner, Advanced, Expert tiers - Add world-breaking themed messages for Legendary bug-related ranks - Add [S] to skip functionality with right-corner display - Refactor message system with per-line color information - Clean up unused animation code and rename gacha_animation to typing_animation - Remove obsolete animation.rs file entirely - Integrate animation into session summary workflow 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 110007b commit ec3e571

File tree

7 files changed

+921
-37
lines changed

7 files changed

+921
-37
lines changed

generate_all_ascii_titles

-3.95 MB
Binary file not shown.

src/game/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ pub mod stage_manager;
88
pub mod stage_builder;
99
pub mod ascii_digits;
1010
pub mod ascii_rank_titles_generated;
11+
pub mod typing_animation;
12+
pub mod rank_messages;
1113

1214
pub use screens::TypingScreen;
1315
pub use challenge::Challenge;

src/game/rank_messages.rs

Lines changed: 547 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
use crate::Result;
2+
use crate::scoring::{ScoringEngine, RankingTitle};
3+
use crate::game::typing_animation::{TypingAnimation, AnimationPhase};
4+
use crossterm::event::{self, Event, KeyCode};
5+
use ratatui::{
6+
backend::CrosstermBackend,
7+
layout::{Alignment, Constraint, Direction, Layout},
8+
style::Style,
9+
text::{Line, Span, Text},
10+
widgets::Paragraph,
11+
Terminal, Frame,
12+
};
13+
use std::io;
14+
15+
pub struct AnimationScreen;
16+
17+
impl AnimationScreen {
18+
// Helper function to convert crossterm::Color to ratatui::Color
19+
fn convert_crossterm_color(color: crossterm::style::Color) -> ratatui::style::Color {
20+
match color {
21+
crossterm::style::Color::Black => ratatui::style::Color::Black,
22+
crossterm::style::Color::DarkGrey => ratatui::style::Color::DarkGray,
23+
crossterm::style::Color::Red => ratatui::style::Color::Red,
24+
crossterm::style::Color::DarkRed => ratatui::style::Color::DarkGray,
25+
crossterm::style::Color::Green => ratatui::style::Color::Green,
26+
crossterm::style::Color::DarkGreen => ratatui::style::Color::DarkGray,
27+
crossterm::style::Color::Yellow => ratatui::style::Color::Yellow,
28+
crossterm::style::Color::DarkYellow => ratatui::style::Color::DarkGray,
29+
crossterm::style::Color::Blue => ratatui::style::Color::Blue,
30+
crossterm::style::Color::DarkBlue => ratatui::style::Color::DarkGray,
31+
crossterm::style::Color::Magenta => ratatui::style::Color::Magenta,
32+
crossterm::style::Color::DarkMagenta => ratatui::style::Color::DarkGray,
33+
crossterm::style::Color::Cyan => ratatui::style::Color::Cyan,
34+
crossterm::style::Color::DarkCyan => ratatui::style::Color::DarkGray,
35+
crossterm::style::Color::White => ratatui::style::Color::White,
36+
crossterm::style::Color::Grey => ratatui::style::Color::Gray,
37+
_ => ratatui::style::Color::White, // Default fallback
38+
}
39+
}
40+
41+
// Helper function to render typing animation with ratatui
42+
fn render_typing_animation_ratatui(frame: &mut Frame, animation: &TypingAnimation, _ranking_title: &str) {
43+
let area = frame.size();
44+
45+
// Create vertical layout for centering
46+
let chunks = Layout::default()
47+
.direction(Direction::Vertical)
48+
.constraints([
49+
Constraint::Percentage(40), // Top padding
50+
Constraint::Min(4), // Animation area
51+
Constraint::Percentage(40), // Bottom padding
52+
])
53+
.split(area);
54+
55+
match animation.get_current_phase() {
56+
AnimationPhase::ConcentrationLines => {
57+
let mut lines = Vec::new();
58+
59+
for (i, line) in animation.get_hacking_lines().iter().enumerate() {
60+
let text = &line.text[..line.typed_length];
61+
let line_color = Self::convert_crossterm_color(line.color);
62+
63+
if i == animation.get_current_line() && line.typed_length < line.text.len() && !line.completed {
64+
// Show cursor on current line
65+
lines.push(Line::from(vec![
66+
Span::styled(text, Style::default().fg(line_color)),
67+
Span::styled("█", Style::default().fg(ratatui::style::Color::White)),
68+
]));
69+
} else if !text.is_empty() {
70+
// Regular completed or typing line
71+
lines.push(Line::from(
72+
Span::styled(text, Style::default().fg(line_color))
73+
));
74+
} else {
75+
// Empty placeholder line
76+
lines.push(Line::from(""));
77+
}
78+
}
79+
80+
let paragraph = Paragraph::new(Text::from(lines))
81+
.alignment(Alignment::Center);
82+
83+
frame.render_widget(paragraph, chunks[1]);
84+
85+
// Render skip hint in bottom right
86+
Self::render_skip_hint(frame, area);
87+
}
88+
AnimationPhase::Pause => {
89+
// Show all completed lines plus dots
90+
let mut lines = Vec::new();
91+
92+
for line in animation.get_hacking_lines().iter() {
93+
let line_color = Self::convert_crossterm_color(line.color);
94+
lines.push(Line::from(
95+
Span::styled(&line.text, Style::default().fg(line_color))
96+
));
97+
}
98+
99+
// Add dots line
100+
let dots = ".".repeat(animation.get_pause_dots());
101+
lines.push(Line::from(
102+
Span::styled(dots, Style::default().fg(ratatui::style::Color::Gray))
103+
));
104+
105+
let paragraph = Paragraph::new(Text::from(lines))
106+
.alignment(Alignment::Center);
107+
108+
frame.render_widget(paragraph, chunks[1]);
109+
110+
// Render skip hint in bottom right
111+
Self::render_skip_hint(frame, area);
112+
}
113+
AnimationPhase::Complete => {
114+
// Animation is complete, ready to transition to result
115+
}
116+
}
117+
}
118+
119+
// Helper function to render skip hint in bottom right corner
120+
fn render_skip_hint(frame: &mut Frame, area: ratatui::layout::Rect) {
121+
let skip_text = "[S] Skip";
122+
let skip_width = skip_text.len() as u16;
123+
let skip_height = 1;
124+
125+
// Position in bottom right corner with small margin
126+
let skip_x = area.width.saturating_sub(skip_width + 1);
127+
let skip_y = area.height.saturating_sub(skip_height + 1);
128+
129+
let skip_area = ratatui::layout::Rect {
130+
x: skip_x,
131+
y: skip_y,
132+
width: skip_width,
133+
height: skip_height,
134+
};
135+
136+
let skip_paragraph = Paragraph::new(skip_text)
137+
.style(Style::default().fg(ratatui::style::Color::Gray));
138+
139+
frame.render_widget(skip_paragraph, skip_area);
140+
}
141+
142+
// Helper function to get tier from ranking title name
143+
fn get_tier_from_title(title_name: &str) -> crate::scoring::RankingTier {
144+
RankingTitle::all_titles()
145+
.iter()
146+
.find(|title| title.name() == title_name)
147+
.map(|title| title.tier().clone())
148+
.unwrap_or(crate::scoring::RankingTier::Beginner)
149+
}
150+
151+
pub fn show_session_animation(
152+
_total_stages: usize,
153+
_completed_stages: usize,
154+
stage_engines: &[(String, ScoringEngine)],
155+
) -> Result<()> {
156+
// Calculate aggregated session metrics by combining ScoringEngines with + operator
157+
if stage_engines.is_empty() {
158+
return Ok(());
159+
}
160+
161+
let combined_engine = stage_engines.iter()
162+
.map(|(_, engine)| engine.clone())
163+
.reduce(|acc, engine| acc + engine)
164+
.unwrap(); // Safe because we checked is_empty() above
165+
166+
let session_metrics = match combined_engine.calculate_metrics() {
167+
Ok(metrics) => metrics,
168+
Err(_) => {
169+
// Fallback if calculation fails
170+
return Ok(());
171+
}
172+
};
173+
174+
// Set up ratatui terminal
175+
let backend = CrosstermBackend::new(io::stdout());
176+
let mut terminal = Terminal::new(backend)?;
177+
terminal.clear()?;
178+
179+
// Create typing animation for session complete
180+
let tier = Self::get_tier_from_title(&session_metrics.ranking_title);
181+
let mut typing_animation = TypingAnimation::new(tier, terminal.size()?.width, terminal.size()?.height);
182+
typing_animation.set_rank_messages(&session_metrics.ranking_title);
183+
184+
// Show typing reveal animation with ratatui
185+
while !typing_animation.is_complete() {
186+
let updated = typing_animation.update();
187+
188+
if updated {
189+
let ranking_title = session_metrics.ranking_title.clone();
190+
terminal.draw(|frame| {
191+
Self::render_typing_animation_ratatui(frame, &typing_animation, &ranking_title);
192+
})?;
193+
}
194+
195+
// Check for S key to skip animation
196+
if event::poll(std::time::Duration::from_millis(50))? {
197+
if let Event::Key(key_event) = event::read()? {
198+
match key_event.code {
199+
KeyCode::Char('s') | KeyCode::Char('S') => {
200+
break;
201+
}
202+
_ => {
203+
// Ignore other keys to prevent accidental skipping
204+
}
205+
}
206+
}
207+
}
208+
209+
std::thread::sleep(std::time::Duration::from_millis(16)); // ~60fps
210+
}
211+
212+
Ok(())
213+
}
214+
}

src/game/screens/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ pub mod result_screen;
33
pub mod countdown_screen;
44
pub mod typing_screen;
55
pub mod loading_screen;
6+
pub mod animation_screen;
67

78
pub use title_screen::{TitleScreen, TitleAction};
89
pub use result_screen::{ResultScreen, ResultAction};
910
pub use countdown_screen::CountdownScreen;
1011
pub use typing_screen::TypingScreen;
11-
pub use loading_screen::LoadingScreen;
12+
pub use loading_screen::LoadingScreen;
13+
pub use animation_screen::AnimationScreen;

src/game/screens/result_screen.rs

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -71,45 +71,16 @@ impl ResultScreen {
7171
let center_row = terminal_height / 2;
7272
let center_col = terminal_width / 2;
7373

74-
// Display ranking title as large ASCII art at the top
75-
let rank_title_lines = get_rank_title_display(&metrics.ranking_title);
76-
let rank_title_height = rank_title_lines.len() as u16;
77-
78-
// Calculate total content height and center vertically
79-
let total_content_height = rank_title_height + 1 + 1 + 4 + 1 + 1 + 4; // rank + gap + label + score + gap + metrics + options
80-
let rank_start_row = if total_content_height < terminal_height {
81-
center_row.saturating_sub(total_content_height / 2)
82-
} else {
83-
3
84-
};
85-
86-
// Display stage title at the top
74+
// Display stage title at the center
8775
let stage_title = format!("🎯 STAGE {} COMPLETE 🎯", current_stage);
8876
let title_col = center_col.saturating_sub(stage_title.len() as u16 / 2);
89-
execute!(stdout, MoveTo(title_col, rank_start_row.saturating_sub(4)))?;
77+
execute!(stdout, MoveTo(title_col, center_row.saturating_sub(6)))?;
9078
execute!(stdout, SetAttribute(Attribute::Bold), SetForegroundColor(Color::Yellow))?;
9179
execute!(stdout, Print(&stage_title))?;
9280
execute!(stdout, ResetColor)?;
9381

94-
// Display "you're:" label before rank title (no gap)
95-
let youre_label = "YOU'RE:";
96-
let youre_col = center_col.saturating_sub(youre_label.len() as u16 / 2);
97-
execute!(stdout, MoveTo(youre_col, rank_start_row.saturating_sub(1)))?;
98-
execute!(stdout, SetAttribute(Attribute::Bold), SetForegroundColor(Color::Cyan))?;
99-
execute!(stdout, Print(youre_label))?;
100-
execute!(stdout, ResetColor)?;
101-
102-
for (row_index, line) in rank_title_lines.iter().enumerate() {
103-
// Calculate actual display width without ANSI codes for centering
104-
let display_width = Self::calculate_display_width(line);
105-
let line_col = center_col.saturating_sub(display_width / 2);
106-
execute!(stdout, MoveTo(line_col, rank_start_row + row_index as u16))?;
107-
execute!(stdout, Print(line))?;
108-
execute!(stdout, ResetColor)?;
109-
}
110-
111-
// Calculate dynamic positioning based on rank title height
112-
let score_label_row = rank_start_row + rank_title_height + 1;
82+
// Position score label below title
83+
let score_label_row = center_row.saturating_sub(3);
11384

11485
// Display "SCORE" label
11586
let score_label = "SCORE";
@@ -196,6 +167,20 @@ impl ResultScreen {
196167
}
197168

198169
pub fn show_session_summary(
170+
total_stages: usize,
171+
completed_stages: usize,
172+
stage_engines: &[(String, ScoringEngine)],
173+
) -> Result<()> {
174+
use crate::game::screens::AnimationScreen;
175+
176+
// First show the animation
177+
AnimationScreen::show_session_animation(total_stages, completed_stages, stage_engines)?;
178+
179+
// Then show the original result screen
180+
Self::show_session_summary_original(total_stages, completed_stages, stage_engines)
181+
}
182+
183+
pub fn show_session_summary_original(
199184
_total_stages: usize,
200185
_completed_stages: usize,
201186
stage_engines: &[(String, ScoringEngine)],
@@ -357,11 +342,17 @@ impl ResultScreen {
357342
}
358343

359344
pub fn show_session_summary_with_input(
360-
_total_stages: usize,
361-
_completed_stages: usize,
345+
total_stages: usize,
346+
completed_stages: usize,
362347
stage_engines: &[(String, ScoringEngine)],
363348
) -> Result<ResultAction> {
364-
Self::show_session_summary(_total_stages, _completed_stages, stage_engines)?;
349+
use crate::game::screens::AnimationScreen;
350+
351+
// First show the animation
352+
AnimationScreen::show_session_animation(total_stages, completed_stages, stage_engines)?;
353+
354+
// Then show the original result screen
355+
Self::show_session_summary_original(total_stages, completed_stages, stage_engines)?;
365356

366357
// Wait for user input and return action
367358
loop {

0 commit comments

Comments
 (0)