Typewriter Text Effect

2022-05-03

A common effect in games is to have text progressively appear letter-by-letter as if it is being actively typed. If all you need is a single line of text, you can achieve this with a simple system that adds letters to a TextSection. But this naive approach has issues when the text occupies multiple lines. When the "typewriter" runs out of room on a line of text while typing a word, that entire word will abruptly move to the next line!

We can get around this by laying out the entire contents of the text immediately but hiding it and progressively making a portion of it visible.

Bevy 0.14
#[derive(Component)]
struct Typewriter(Timer);

impl Typewriter {
    fn new(delay: f32) -> Self {
        Self(Timer::from_seconds(delay, TimerMode::Repeating))
    }
}

fn update_typewriters(
    time: Res<Time>,
    mut query: Query<(&mut Text, &mut Typewriter), With<Typewriter>>,
) {
    for (mut text, mut typewriter) in query.iter_mut() {
        if !typewriter.0.tick(time.delta()).just_finished() {
            return;
        }

        while !text.sections[1].value.is_empty() {
            // Remove a char from the section containing hidden characters and place
            // it in the section for visible characters.
            let first_hidden = text.sections[1].value.remove(0);

            text.sections[0].value.push(first_hidden);

            if first_hidden != ' ' {
                break;
            }
        }
    }
}

fn setup(mut commands: Commands) {
    let container = commands
        .spawn(NodeBundle {
            style: Style {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                align_items: AlignItems::Center,
                justify_content: JustifyContent::Center,
                ..default()
            },
            ..default()
        })
        .id();

    let bg = commands
        .spawn(NodeBundle {
            style: Style {
                width: Val::Px(680.0),
                height: Val::Px(300.0),
                padding: UiRect::all(Val::Px(10.)),
                ..default()
            },
            border_radius: BorderRadius::all(Val::Px(10.)),
            background_color: Srgba::gray(0.2).into(),
            ..default()
        })
        .id();

    let typewriter = commands
        .spawn((
            Typewriter::new(0.1),
            TextBundle {
                style: Style {
                    width: Val::Percent(100.),
                    ..default()
                },
                text: Text::from_sections([
                    // The (initially empty) section that will contain visible text.
                    TextSection {
                        value: "".to_string(),
                        style: TextStyle::default(),
                    },
                    // The section that contains hidden text.
                    TextSection {
                        value: TEXT.into(),
                        style: TextStyle {
                            color: Color::NONE,
                            ..default()
                        },
                    },
                ]),
                ..default()
            },
        ))
        .id();

    commands.entity(container).add_child(bg);
    commands.entity(bg).add_child(typewriter);
}