bevy_director 0.7.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
Documentation
//! The built-in text overlay: renders [`ActiveTexts`] as bevy_ui nodes
//! on the cine camera. The overlay root carries `UiTargetCamera`, so
//! layout happens in the camera's viewport — anchors stay inside the
//! letterbox crop without any math here. Games that want different
//! captions ignore this plugin and read [`ActiveTexts`] themselves.

use bevy::prelude::*;

use crate::{
    DirectorSet,
    eval::ActiveText,
    player::{ActiveTexts, DirectorState},
    sequence::{TextAnchor, TextBlockStyle},
};

/// The shadow's base opacity; block fades scale it down further.
const SHADOW_ALPHA: f32 = 0.75;

/// Overlay looks that are not per-block: font override, stacking, zone
/// insets. Insert a customized copy before adding [`TitlesPlugin`] (or
/// mutate the resource at runtime).
#[derive(Resource)]
pub struct TitlesConfig {
    /// Font for every block. None uses bevy's `default_font` (embedded
    /// Fira Mono subset, enabled by the `titles` feature).
    pub font: Option<Handle<Font>>,
    /// `GlobalZIndex` of the overlay root. Below the editor dock (1000).
    pub z_index: i32,
    /// TopCenter zone inset from the viewport top, percent.
    pub top_offset_percent: f32,
    /// LowerThird zone inset from the viewport bottom, percent.
    pub lower_third_offset_percent: f32,
    /// Padding around blocks that carry a background box, px.
    pub background_padding: f32,
    pub shadow_offset: Vec2,
}

impl Default for TitlesConfig {
    fn default() -> Self {
        Self {
            font: None,
            z_index: 500,
            top_offset_percent: 8.0,
            lower_third_offset_percent: 10.0,
            background_padding: 8.0,
            shadow_offset: Vec2::splat(2.0),
        }
    }
}

/// The overlay's root node, one per active take. Despawned whenever no
/// text is on. Public so games can inspect or restyle the tree.
#[derive(Component)]
pub struct TitleOverlayRoot;

/// One of the three anchor containers under the root.
#[derive(Component)]
pub struct TitleAnchorZone(pub TextAnchor);

/// One rendered block: the wrapper node holding the background box.
#[derive(Component)]
struct TitleNode {
    /// Index into the sequence's texts, mirroring [`ActiveText::index`].
    index: usize,
    anchor: TextAnchor,
    style: TextBlockStyle,
}

/// Renders text blocks during playback and editor preview. Requires
/// [`DirectorPlugin`](crate::DirectorPlugin) (or the viewfinder/editor
/// stack) for the data, and the app's `DefaultPlugins` for bevy_ui.
pub struct TitlesPlugin;

impl Plugin for TitlesPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<ActiveTexts>()
            .init_resource::<TitlesConfig>()
            .add_systems(PostUpdate, sync_title_overlay.after(DirectorSet::Apply));
    }
}

fn base_background(style: &TextBlockStyle) -> Color {
    style.background.unwrap_or(Color::NONE)
}

/// Keep the overlay tree mirroring [`ActiveTexts`]: despawn when idle,
/// spawn missing blocks under their anchor zone, retire stale ones, and
/// write this frame's fade alphas onto colors. Structural changes land
/// next frame (bevy_ui lays out at the end of PostUpdate), which fades
/// hide; color writes are picked up the same frame.
#[allow(clippy::too_many_arguments)] // the overlay's one write-out
fn sync_title_overlay(
    mut commands: Commands,
    active: Res<ActiveTexts>,
    state: Res<DirectorState>,
    config: Res<TitlesConfig>,
    roots: Query<(Entity, &UiTargetCamera), With<TitleOverlayRoot>>,
    zones: Query<(Entity, &TitleAnchorZone)>,
    mut nodes: Query<(Entity, &TitleNode, &mut BackgroundColor, &Children)>,
    mut texts: Query<(&mut Text, &mut TextColor, Option<&mut TextShadow>)>,
) {
    let camera = state.camera;
    if active.blocks.is_empty() || camera.is_none() {
        for (root, _) in &roots {
            commands.entity(root).despawn();
        }
        return;
    }
    let camera = camera.unwrap();

    let Some((root, target)) = roots.iter().next() else {
        // First sight of text: build the whole tree in one flush so the
        // zones exist before any block needs them.
        spawn_overlay(&mut commands, camera, &config, &active.blocks);
        return;
    };
    if target.0 != camera {
        commands.entity(root).insert(UiTargetCamera(camera));
    }

    // Retire or refresh what exists.
    let mut alive: Vec<usize> = Vec::with_capacity(active.blocks.len());
    for (entity, node, mut background, children) in &mut nodes {
        let matching = active.blocks.iter().find(|a| a.index == node.index);
        match matching {
            // A changed anchor or style respawns the node below; text
            // content and alpha update in place.
            Some(a) if node.anchor == a.block.anchor && node.style == a.block.style => {
                alive.push(node.index);
                let bg = base_background(&node.style);
                background.0 = bg.with_alpha(bg.alpha() * a.alpha);
                for child in children {
                    let Ok((mut text, mut color, shadow)) = texts.get_mut(*child) else {
                        continue;
                    };
                    if text.0 != a.block.text {
                        text.0.clone_from(&a.block.text);
                    }
                    let base = node.style.color;
                    color.0 = base.with_alpha(base.alpha() * a.alpha);
                    if let Some(mut shadow) = shadow {
                        shadow.color = Color::BLACK.with_alpha(SHADOW_ALPHA * a.alpha);
                    }
                }
            }
            _ => commands.entity(entity).despawn(),
        }
    }

    // Spawn what is new.
    for a in &active.blocks {
        if alive.contains(&a.index) {
            continue;
        }
        let zone = zones
            .iter()
            .find(|(_, zone)| zone.0 == a.block.anchor)
            .map(|(entity, _)| entity);
        if let Some(zone) = zone {
            spawn_title_node(&mut commands, zone, a, &config);
        }
    }
}

fn spawn_overlay(
    commands: &mut Commands,
    camera: Entity,
    config: &TitlesConfig,
    blocks: &[ActiveText],
) {
    let root = commands
        .spawn((
            Name::new("director titles"),
            TitleOverlayRoot,
            Node {
                position_type: PositionType::Absolute,
                left: px(0),
                right: px(0),
                top: px(0),
                bottom: px(0),
                ..default()
            },
            GlobalZIndex(config.z_index),
            UiTargetCamera(camera),
        ))
        .id();
    #[cfg(feature = "editor")]
    commands.entity(root).insert(Pickable::IGNORE);

    let zone_layouts = [
        (
            TextAnchor::TopCenter,
            Node {
                position_type: PositionType::Absolute,
                top: percent(config.top_offset_percent),
                left: px(0),
                right: px(0),
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::Center,
                row_gap: px(8),
                ..default()
            },
        ),
        (
            TextAnchor::Center,
            Node {
                position_type: PositionType::Absolute,
                left: px(0),
                right: px(0),
                top: px(0),
                bottom: px(0),
                flex_direction: FlexDirection::Column,
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                row_gap: px(8),
                ..default()
            },
        ),
        (
            TextAnchor::LowerThird,
            Node {
                position_type: PositionType::Absolute,
                bottom: percent(config.lower_third_offset_percent),
                left: px(0),
                right: px(0),
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::Center,
                row_gap: px(8),
                ..default()
            },
        ),
    ];
    for (anchor, layout) in zone_layouts {
        let zone = commands
            .spawn((TitleAnchorZone(anchor), layout, ChildOf(root)))
            .id();
        #[cfg(feature = "editor")]
        commands.entity(zone).insert(Pickable::IGNORE);
        for block in blocks.iter().filter(|a| a.block.anchor == anchor) {
            spawn_title_node(commands, zone, block, config);
        }
    }
}

fn spawn_title_node(
    commands: &mut Commands,
    zone: Entity,
    active: &ActiveText,
    config: &TitlesConfig,
) {
    let style = active.block.style.clone();
    let bg = base_background(&style);
    let padding = if style.background.is_some() {
        config.background_padding
    } else {
        0.0
    };
    let mut font = TextFont::from_font_size(style.font_size);
    if let Some(handle) = &config.font {
        font = font.with_font(handle.clone());
    }

    let mut wrapper = commands.spawn((
        TitleNode {
            index: active.index,
            anchor: active.block.anchor,
            style: style.clone(),
        },
        Node {
            padding: UiRect::all(px(padding)),
            ..default()
        },
        BackgroundColor(bg.with_alpha(bg.alpha() * active.alpha)),
        ChildOf(zone),
    ));
    #[cfg(feature = "editor")]
    wrapper.insert(Pickable::IGNORE);
    wrapper.with_children(|parent| {
        let mut text = parent.spawn((
            Text::new(active.block.text.clone()),
            font,
            TextColor(style.color.with_alpha(style.color.alpha() * active.alpha)),
            TextLayout::justify(Justify::Center),
        ));
        if style.shadow {
            text.insert(TextShadow {
                offset: config.shadow_offset,
                color: Color::BLACK.with_alpha(SHADOW_ALPHA * active.alpha),
            });
        }
        #[cfg(feature = "editor")]
        text.insert(Pickable::IGNORE);
    });
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sequence::TextBlock;

    #[test]
    fn stale_and_changed_nodes_get_respawned_fresh_ones_kept() {
        // The reconcile decision, minus the ECS: a node survives only
        // when an active block with its index, anchor, and style exists.
        let survives = |node: &(usize, TextAnchor, TextBlockStyle), active: &[ActiveText]| {
            active
                .iter()
                .any(|a| a.index == node.0 && a.block.anchor == node.1 && a.block.style == node.2)
        };
        let block = TextBlock::at(0.0, 2.0, "one").anchor(TextAnchor::Center);
        let active = [ActiveText {
            index: 3,
            alpha: 1.0,
            block: block.clone(),
        }];
        let same = (3, TextAnchor::Center, TextBlockStyle::default());
        let moved = (3, TextAnchor::LowerThird, TextBlockStyle::default());
        let stale = (7, TextAnchor::Center, TextBlockStyle::default());
        assert!(survives(&same, &active));
        assert!(!survives(&moved, &active));
        assert!(!survives(&stale, &active));
    }
}