use bevy::prelude::*;
use crate::{
DirectorSet,
eval::ActiveText,
player::{ActiveTexts, DirectorState},
sequence::{TextAnchor, TextBlockStyle},
};
const SHADOW_ALPHA: f32 = 0.75;
#[derive(Resource)]
pub struct TitlesConfig {
pub font: Option<Handle<Font>>,
pub z_index: i32,
pub top_offset_percent: f32,
pub lower_third_offset_percent: f32,
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),
}
}
}
#[derive(Component)]
pub struct TitleOverlayRoot;
#[derive(Component)]
pub struct TitleAnchorZone(pub TextAnchor);
#[derive(Component)]
struct TitleNode {
index: usize,
anchor: TextAnchor,
style: TextBlockStyle,
}
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)
}
#[allow(clippy::too_many_arguments)] 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 {
spawn_overlay(&mut commands, camera, &config, &active.blocks);
return;
};
if target.0 != camera {
commands.entity(root).insert(UiTargetCamera(camera));
}
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 {
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(),
}
}
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() {
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));
}
}