use bevy::{
camera::Exposure,
post_process::dof::{DepthOfField, DepthOfFieldMode},
prelude::*,
};
use crate::{
eval::{ActiveActorCue, ActiveText, CameraPose, CameraSnapshot, CompiledSequence, EvalCtx, bake, mix},
letterbox::LetterboxSettings,
sequence::{Blend, SequenceAsset},
};
#[derive(Component, Default)]
pub struct CineCamera;
#[derive(Component)]
pub struct HandoffCamera;
#[derive(Component)]
pub struct SequencePlayer {
pub sequence: Handle<SequenceAsset>,
pub playhead: f32,
pub rate: f32,
pub playback: Playback,
pub clock: ClockSource,
pub loop_mode: LoopMode,
}
impl SequencePlayer {
pub fn new(sequence: Handle<SequenceAsset>) -> Self {
Self {
sequence,
playhead: 0.0,
rate: 1.0,
playback: Playback::Playing,
clock: ClockSource::default(),
loop_mode: LoopMode::default(),
}
}
pub fn seek(&mut self, t: f32) {
self.playhead = t.max(0.0);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Playback {
Stopped,
#[default]
Playing,
Paused,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClockSource {
#[default]
Virtual,
Real,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LoopMode {
#[default]
Once,
Loop,
PingPong,
}
#[derive(Resource, Default)]
pub struct DirectorState {
pub phase: DirectorPhase,
pub camera: Option<Entity>,
pub live_camera: Option<Entity>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirectorPhase {
#[default]
Idle,
Shooting,
Handback,
Viewfinder,
}
#[derive(Resource, Default, Debug, Clone)]
pub struct ActiveTexts {
pub blocks: Vec<ActiveText>,
}
#[derive(Resource, Default, Debug, Clone)]
pub struct ActiveActorCues {
pub cues: Vec<ActiveActorCue>,
}
pub fn director_idle(state: Res<DirectorState>) -> bool {
state.phase == DirectorPhase::Idle
}
pub fn director_active(state: Res<DirectorState>) -> bool {
state.phase != DirectorPhase::Idle
}
pub fn gameplay_camera_free(state: Res<DirectorState>) -> bool {
matches!(state.phase, DirectorPhase::Idle | DirectorPhase::Handback)
}
#[derive(Message, Debug, Clone)]
pub struct SequenceStarted {
pub camera: Entity,
pub sequence: AssetId<SequenceAsset>,
}
#[derive(Message, Debug, Clone)]
pub struct MarkerReached {
pub camera: Entity,
pub name: String,
pub time: f32,
}
#[derive(Message, Debug, Clone)]
pub struct SequenceFinished {
pub camera: Entity,
pub reason: FinishReason,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FinishReason {
Completed,
Skipped,
Stopped,
}
#[derive(Message)]
pub(crate) enum DirectorRequest {
Play {
handle: Handle<SequenceAsset>,
options: PlayOptions,
},
Skip,
Stop,
}
#[derive(Clone, Debug)]
pub struct PlayOptions {
pub rate: f32,
pub clock: ClockSource,
pub loop_mode: LoopMode,
pub blend_out: Option<Blend>,
pub letterbox: Option<f32>,
}
impl Default for PlayOptions {
fn default() -> Self {
Self {
rate: 1.0,
clock: ClockSource::default(),
loop_mode: LoopMode::default(),
blend_out: None,
letterbox: None,
}
}
}
pub trait DirectorCommands {
fn play_sequence(&mut self, handle: Handle<SequenceAsset>);
fn play_sequence_with(&mut self, handle: Handle<SequenceAsset>, options: PlayOptions);
fn skip_sequence(&mut self);
fn stop_sequence(&mut self);
}
impl DirectorCommands for Commands<'_, '_> {
fn play_sequence(&mut self, handle: Handle<SequenceAsset>) {
self.play_sequence_with(handle, PlayOptions::default());
}
fn play_sequence_with(&mut self, handle: Handle<SequenceAsset>, options: PlayOptions) {
self.queue(move |world: &mut World| {
world.write_message(DirectorRequest::Play { handle, options });
});
}
fn skip_sequence(&mut self) {
self.queue(|world: &mut World| {
world.write_message(DirectorRequest::Skip);
});
}
fn stop_sequence(&mut self) {
self.queue(|world: &mut World| {
world.write_message(DirectorRequest::Stop);
});
}
}
#[derive(Component)]
pub(crate) struct Baked {
pub(crate) compiled: CompiledSequence,
pub(crate) live: CameraSnapshot,
pub(crate) marker_cursor: f32,
pub(crate) blend_out: Option<Blend>,
pub(crate) last_pose: Option<CameraPose>,
pub(crate) dof_active: bool,
pub(crate) exposure_active: bool,
}
#[derive(Component)]
pub(crate) struct PendingPlay {
handle: Handle<SequenceAsset>,
options: PlayOptions,
}
#[derive(Component)]
pub(crate) struct HandbackBlend {
from: CameraPose,
elapsed: f32,
blend: Blend,
clock: ClockSource,
}
pub(crate) fn snapshot_of(
transform: &Transform,
projection: Option<&Projection>,
) -> CameraSnapshot {
let fov_y = match projection {
Some(Projection::Perspective(p)) => p.fov,
_ => 45f32.to_radians(),
};
CameraSnapshot {
position: transform.translation,
rotation: transform.rotation,
fov_y,
}
}
fn resolve_live_camera(
cameras: &Query<(Entity, &Camera, Has<HandoffCamera>), (With<Camera3d>, Without<CineCamera>)>,
) -> Option<Entity> {
if let Some((e, ..)) = cameras.iter().find(|(_, _, marked)| *marked) {
return Some(e);
}
let mut actives = cameras.iter().filter(|(_, cam, _)| cam.is_active);
let first = actives.next().map(|(e, ..)| e);
if actives.next().is_some() {
warn!("several active cameras and no HandoffCamera marker; picking one arbitrarily");
}
first
}
fn spawn_fallback_cine_camera(commands: &mut Commands, active: bool) -> Entity {
commands
.spawn((
Name::new("cine camera"),
CineCamera,
Camera3d::default(),
Camera {
is_active: active,
..Default::default()
},
Projection::default(),
Transform::default(),
))
.id()
}
#[allow(clippy::too_many_arguments)] pub(crate) fn handle_requests(
mut commands: Commands,
mut requests: MessageReader<DirectorRequest>,
mut state: ResMut<DirectorState>,
assets: Res<Assets<SequenceAsset>>,
mut cameras: ParamSet<(
Query<(Entity, &Camera, Has<HandoffCamera>), (With<Camera3d>, Without<CineCamera>)>,
Query<&mut Camera>,
)>,
cine_cameras: Query<Entity, With<CineCamera>>,
pending: Query<(Entity, &PendingPlay)>,
mut players: Query<(&mut SequencePlayer, Option<&Baked>)>,
transforms: Query<(&Transform, Option<&Projection>)>,
mut started: MessageWriter<SequenceStarted>,
mut finished: MessageWriter<SequenceFinished>,
) {
let retry: Option<(Handle<SequenceAsset>, PlayOptions, Entity)> = pending
.iter()
.next()
.filter(|(_, p)| assets.contains(&p.handle))
.map(|(e, p)| (p.handle.clone(), p.options.clone(), e));
let mut plays: Vec<(Handle<SequenceAsset>, PlayOptions)> = Vec::new();
if let Some((handle, options, entity)) = retry {
commands.entity(entity).remove::<PendingPlay>();
plays.push((handle, options));
}
for request in requests.read() {
match request {
DirectorRequest::Play { handle, options } => {
plays.push((handle.clone(), options.clone()));
}
DirectorRequest::Skip => {
if state.phase == DirectorPhase::Shooting
&& let Some(camera) = state.camera
&& let Ok((mut player, baked)) = players.get_mut(camera)
&& let Some(baked) = baked
{
player.playhead = baked.compiled.duration();
player.playback = Playback::Playing;
player.loop_mode = LoopMode::Once;
finished.write(SequenceFinished {
camera,
reason: FinishReason::Skipped,
});
commands.entity(camera).insert(SkipRequested);
}
}
DirectorRequest::Stop => {
if state.phase == DirectorPhase::Shooting || state.phase == DirectorPhase::Handback
{
if let Some(camera) = state.camera {
finished.write(SequenceFinished {
camera,
reason: FinishReason::Stopped,
});
}
let mut writable = cameras.p1();
restore_live(&mut commands, &mut state, &mut writable);
}
}
}
}
for (handle, options) in plays {
if state.phase != DirectorPhase::Idle {
warn!(
"play_sequence ignored: the director is already {:?}",
state.phase
);
continue;
}
let existing = cine_cameras.iter().next();
let Some(asset) = assets.get(&handle) else {
let cine = existing.unwrap_or_else(|| spawn_fallback_cine_camera(&mut commands, false));
commands.entity(cine).insert(PendingPlay {
handle: handle.clone(),
options,
});
continue;
};
let cine = existing.unwrap_or_else(|| spawn_fallback_cine_camera(&mut commands, true));
let compiled = match bake(asset) {
Ok(compiled) => compiled,
Err(err) => {
error!("sequence '{}' failed to bake: {err}", asset.name);
continue;
}
};
let live_entity = resolve_live_camera(&cameras.p0());
let live = live_entity
.and_then(|e| transforms.get(e).ok())
.map(|(t, p)| snapshot_of(t, p))
.unwrap_or(CameraSnapshot {
position: Vec3::ZERO,
rotation: Quat::IDENTITY,
fov_y: 45f32.to_radians(),
});
let blend_out = options.blend_out.or(compiled.blend_out());
let mut cine_commands = commands.entity(cine);
cine_commands.insert((
SequencePlayer {
sequence: handle.clone(),
playhead: 0.0,
rate: options.rate,
playback: Playback::Playing,
clock: options.clock,
loop_mode: options.loop_mode,
},
Baked {
compiled,
live,
marker_cursor: -f32::EPSILON,
blend_out,
last_pose: None,
dof_active: false,
exposure_active: false,
},
));
if let Some(aspect) = options.letterbox {
cine_commands.insert(LetterboxSettings { aspect });
}
let mut writable = cameras.p1();
if let Some(live_entity) = live_entity
&& let Ok(mut cam) = writable.get_mut(live_entity)
{
cam.is_active = false;
}
if let Ok(mut cam) = writable.get_mut(cine) {
cam.is_active = true;
}
state.phase = DirectorPhase::Shooting;
state.camera = Some(cine);
state.live_camera = live_entity;
started.write(SequenceStarted {
camera: cine,
sequence: handle.id(),
});
}
}
#[derive(Component)]
pub(crate) struct SkipRequested;
pub(crate) fn rebake_on_asset_change(
mut events: MessageReader<AssetEvent<SequenceAsset>>,
assets: Res<Assets<SequenceAsset>>,
mut players: Query<(&mut SequencePlayer, &mut Baked)>,
) {
for event in events.read() {
let AssetEvent::Modified { id } = event else {
continue;
};
for (mut player, mut baked) in &mut players {
if player.sequence.id() != *id {
continue;
}
let Some(asset) = assets.get(*id) else {
continue;
};
match bake(asset) {
Ok(compiled) => {
player.playhead = player.playhead.min(compiled.duration());
baked.compiled = compiled;
info!("sequence '{}' rebaked from disk", asset.name);
}
Err(err) => error!("sequence '{}' failed to rebake: {err}", asset.name),
}
}
}
}
pub(crate) fn guard_orphans(
mut commands: Commands,
mut state: ResMut<DirectorState>,
players: Query<&SequencePlayer>,
mut cameras: Query<&mut Camera>,
mut finished: MessageWriter<SequenceFinished>,
) {
if state.phase == DirectorPhase::Idle {
return;
}
let camera_gone = state.camera.is_none_or(|e| {
if state.phase == DirectorPhase::Viewfinder {
cameras.get(e).is_err()
} else {
players.get(e).is_err()
}
});
if !camera_gone {
return;
}
if let Some(camera) = state.camera {
finished.write(SequenceFinished {
camera,
reason: FinishReason::Stopped,
});
}
restore_live(&mut commands, &mut state, &mut cameras);
}
pub(crate) fn restore_live(
commands: &mut Commands,
state: &mut DirectorState,
cameras: &mut Query<&mut Camera>,
) {
if let Some(live) = state.live_camera
&& let Ok(mut cam) = cameras.get_mut(live)
{
cam.is_active = true;
}
if let Some(cine) = state.camera {
if let Ok(mut cam) = cameras.get_mut(cine) {
cam.is_active = false;
cam.viewport = None;
}
if let Ok(mut e) = commands.get_entity(cine) {
e.remove::<(
SequencePlayer,
Baked,
HandbackBlend,
PendingPlay,
SkipRequested,
LetterboxSettings,
DepthOfField,
Exposure,
)>();
}
}
state.phase = DirectorPhase::Idle;
state.camera = None;
state.live_camera = None;
}
pub(crate) fn tick_players(
mut commands: Commands,
mut state: ResMut<DirectorState>,
virtual_time: Res<Time<Virtual>>,
real_time: Res<Time<Real>>,
mut players: Query<(Entity, &mut SequencePlayer, &mut Baked, Has<SkipRequested>)>,
mut cameras: Query<&mut Camera>,
mut markers: MessageWriter<MarkerReached>,
mut finished: MessageWriter<SequenceFinished>,
) {
if state.phase != DirectorPhase::Shooting {
return;
}
let Some(camera) = state.camera else {
return;
};
let Ok((entity, mut player, mut baked, skipping)) = players.get_mut(camera) else {
return;
};
if player.playback != Playback::Playing {
return;
}
let dt = match player.clock {
ClockSource::Virtual => virtual_time.delta_secs(),
ClockSource::Real => real_time.delta_secs(),
} * player.rate;
player.playhead += dt;
let duration = baked.compiled.duration();
let cursor = baked.marker_cursor;
if !skipping {
let fire = |m: &crate::sequence::Marker, markers: &mut MessageWriter<MarkerReached>| {
markers.write(MarkerReached {
camera: entity,
name: m.name.clone(),
time: m.time,
});
};
if dt >= 0.0 {
for marker in baked.compiled.markers_between(cursor, player.playhead) {
fire(marker, &mut markers);
}
} else {
for marker in baked
.compiled
.markers_between_backward(player.playhead, cursor)
{
fire(marker, &mut markers);
}
}
}
baked.marker_cursor = player.playhead;
let over = dt >= 0.0 && player.playhead >= duration;
let under = dt < 0.0 && player.playhead <= 0.0;
if !over && !under {
return;
}
match player.loop_mode {
LoopMode::Loop => {
let wrapped = if over {
let wrapped = (player.playhead - duration).max(0.0);
if !skipping {
for marker in baked.compiled.markers_between(-f32::EPSILON, wrapped) {
markers.write(MarkerReached {
camera: entity,
name: marker.name.clone(),
time: marker.time,
});
}
}
wrapped
} else {
(player.playhead + duration).clamp(0.0, duration)
};
player.playhead = wrapped;
baked.marker_cursor = wrapped;
}
LoopMode::PingPong => {
player.rate = -player.rate;
player.playhead = if over {
(2.0 * duration - player.playhead).clamp(0.0, duration)
} else {
(-player.playhead).clamp(0.0, duration)
};
baked.marker_cursor = player.playhead;
}
LoopMode::Once => {
player.playhead = if over { duration } else { 0.0 };
player.playback = Playback::Stopped;
if !skipping {
finished.write(SequenceFinished {
camera: entity,
reason: FinishReason::Completed,
});
}
match baked.blend_out {
Some(blend) if blend.secs > 0.0 => {
let from = baked.last_pose.unwrap_or_else(|| {
baked
.compiled
.pose_at(player.playhead, &EvalCtx::still(&baked.live))
});
commands.entity(entity).insert(HandbackBlend {
from,
elapsed: 0.0,
blend,
clock: player.clock,
});
state.phase = DirectorPhase::Handback;
}
_ => restore_live(&mut commands, &mut state, &mut cameras),
}
}
}
}
#[allow(clippy::too_many_arguments)] pub(crate) fn apply_pose(
mut commands: Commands,
mut state: ResMut<DirectorState>,
virtual_time: Res<Time<Virtual>>,
real_time: Res<Time<Real>>,
mut cine: Query<
(
&mut Transform,
&mut Projection,
Option<(&SequencePlayer, &mut Baked)>,
Option<&mut HandbackBlend>,
),
With<CineCamera>,
>,
live: Query<(&Transform, Option<&Projection>), Without<CineCamera>>,
names: Query<(&Name, &GlobalTransform)>,
mut cameras: Query<&mut Camera>,
) {
let Some(camera) = state.camera else {
return;
};
let Ok((mut transform, mut projection, playing, handback)) = cine.get_mut(camera) else {
return;
};
let resolve = |wanted: &str| {
names
.iter()
.find(|(name, _)| name.as_str() == wanted)
.map(|(_, gt)| gt.translation())
};
let (pose, lens_flags) = match state.phase {
DirectorPhase::Shooting => {
let Some((player, mut baked)) = playing else {
return;
};
let dt = match player.clock {
ClockSource::Virtual => virtual_time.delta_secs(),
ClockSource::Real => real_time.delta_secs(),
};
let ctx = EvalCtx {
live: &baked.live,
dt,
prev_rot: baked.last_pose.map(|p| p.rotation),
resolve_entity: &resolve,
};
let pose = baked.compiled.pose_at(player.playhead, &ctx);
baked.last_pose = Some(pose);
let flags = Some((
pose.dof.is_some() || baked.dof_active,
pose.exposure_ev100.is_some() || baked.exposure_active,
));
baked.dof_active = pose.dof.is_some();
baked.exposure_active = pose.exposure_ev100.is_some();
(pose, flags)
}
DirectorPhase::Handback => {
let Some(mut handback) = handback else {
return;
};
handback.elapsed += match handback.clock {
ClockSource::Virtual => virtual_time.delta_secs(),
ClockSource::Real => real_time.delta_secs(),
};
let target = state
.live_camera
.and_then(|e| live.get(e).ok())
.map(|(t, p)| snapshot_of(t, p))
.unwrap_or(CameraSnapshot {
position: handback.from.position,
rotation: handback.from.rotation,
fov_y: handback.from.fov_y,
});
let target = CameraPose {
position: target.position,
rotation: target.rotation,
fov_y: target.fov_y,
dof: None,
exposure_ev100: None,
};
let w = handback
.blend
.ease
.sample_clamped((handback.elapsed / handback.blend.secs).clamp(0.0, 1.0));
let pose = mix(&handback.from, &target, w);
if handback.elapsed >= handback.blend.secs {
restore_live(&mut commands, &mut state, &mut cameras);
}
(pose, None)
}
_ => return,
};
transform.translation = pose.position;
transform.rotation = pose.rotation;
if let Projection::Perspective(perspective) = &mut *projection {
perspective.fov = pose.fov_y;
}
if let Some((touch_dof, touch_exposure)) = lens_flags {
if touch_dof {
match pose.dof {
Some(d) => {
commands.entity(camera).insert(DepthOfField {
mode: if d.bokeh {
DepthOfFieldMode::Bokeh
} else {
DepthOfFieldMode::Gaussian
},
focal_distance: d.focal_distance,
aperture_f_stops: d.aperture_f_stops,
sensor_height: d.sensor_height,
..Default::default()
});
}
None => {
commands.entity(camera).remove::<DepthOfField>();
}
}
}
if touch_exposure {
match pose.exposure_ev100 {
Some(ev100) => {
commands.entity(camera).insert(Exposure { ev100 });
}
None => {
commands.entity(camera).remove::<Exposure>();
}
}
}
}
}
pub(crate) fn update_active_texts(
state: Res<DirectorState>,
players: Query<(&SequencePlayer, &Baked)>,
mut active: ResMut<ActiveTexts>,
) {
match state.phase {
DirectorPhase::Shooting => {
let playing = state.camera.and_then(|camera| players.get(camera).ok());
match playing {
Some((player, baked)) => {
if baked.compiled.texts().is_empty() && active.blocks.is_empty() {
return;
}
let blocks = &mut active.blocks;
baked.compiled.active_texts_into(player.playhead, blocks);
}
None => {
if !active.blocks.is_empty() {
active.blocks.clear();
}
}
}
}
DirectorPhase::Viewfinder => {}
_ => {
if !active.blocks.is_empty() {
active.blocks.clear();
}
}
}
}
pub(crate) fn update_active_actor_cues(
state: Res<DirectorState>,
players: Query<(&SequencePlayer, &Baked)>,
mut active: ResMut<ActiveActorCues>,
) {
match state.phase {
DirectorPhase::Shooting => {
let playing = state.camera.and_then(|camera| players.get(camera).ok());
match playing {
Some((player, baked)) => {
if baked.compiled.actors().is_empty() && active.cues.is_empty() {
return;
}
let cues = &mut active.cues;
baked.compiled.active_actor_cues_into(player.playhead, cues);
}
None => {
if !active.cues.is_empty() {
active.cues.clear();
}
}
}
}
DirectorPhase::Viewfinder => {}
_ => {
if !active.cues.is_empty() {
active.cues.clear();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DirectorPlugin;
use crate::sequence::*;
use bevy::app::App;
use bevy::asset::AssetPlugin;
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
fn test_sequence(blend_out: Option<Blend>) -> SequenceAsset {
SequenceAsset {
name: "test".into(),
shots: vec![Shot {
start: 0.0,
duration: 0.1,
blend_in: None,
rig: Rig::Keys {
keys: vec![Key {
time: 0.0,
pos: Vec3::new(5.0, 0.0, 0.0),
rot: Some(Quat::IDENTITY),
ease: EaseFunction::Linear,
}],
interp: KeyInterp::Eased,
},
look: Look::Free,
lens: Lens::default(),
shake: None,
}],
markers: vec![Marker {
time: 0.05,
name: "beat".into(),
}],
texts: vec![],
actors: vec![],
blend_out,
}
}
fn test_app() -> (App, Entity, Handle<SequenceAsset>) {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default(), DirectorPlugin));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(
16,
)));
let live = app
.world_mut()
.spawn((
Camera3d::default(),
Camera::default(),
Projection::default(),
Transform::from_xyz(0.0, 9.0, 0.0),
))
.id();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(test_sequence(None));
(app, live, handle)
}
fn phase(app: &App) -> DirectorPhase {
app.world().resource::<DirectorState>().phase
}
fn is_active(app: &mut App, e: Entity) -> bool {
app.world().get::<Camera>(e).unwrap().is_active
}
#[test]
fn play_swaps_is_active_and_finishes() {
let (mut app, live, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
assert!(!is_active(&mut app, live));
let cine = app.world().resource::<DirectorState>().camera.unwrap();
assert!(is_active(&mut app, cine));
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
assert!(!is_active(&mut app, cine));
let t = app.world().get::<Transform>(cine).unwrap();
assert_eq!(t.translation.x, 5.0);
}
#[test]
fn blend_out_routes_through_handback() {
let (mut app, live, _) = test_app();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(test_sequence(Some(Blend {
secs: 0.05,
ease: EaseFunction::Linear,
})));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let mut saw_handback = false;
for _ in 0..40 {
app.update();
if phase(&app) == DirectorPhase::Handback {
saw_handback = true;
}
}
assert!(saw_handback);
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
}
#[test]
fn orphan_guard_restores_live_camera() {
let (mut app, live, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let cine = app.world().resource::<DirectorState>().camera.unwrap();
app.world_mut().entity_mut(cine).despawn();
app.update();
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
}
#[test]
fn pause_of_virtual_clock_freezes_playhead() {
let (mut app, _, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
app.world_mut().resource_mut::<Time<Virtual>>().pause();
for _ in 0..10 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Shooting);
let cine = app.world().resource::<DirectorState>().camera.unwrap();
let head = app.world().get::<SequencePlayer>(cine).unwrap().playhead;
assert!(head <= 0.017, "playhead crept to {head}");
}
#[derive(Resource, Default)]
struct MarkerHits(usize);
fn count_markers(mut hits: ResMut<MarkerHits>, mut reader: MessageReader<MarkerReached>) {
hits.0 += reader.read().count();
}
#[test]
fn marker_fires_exactly_once() {
let (mut app, _, handle) = test_app();
app.init_resource::<MarkerHits>();
app.add_systems(Update, count_markers);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..30 {
app.update();
}
assert_eq!(app.world().resource::<MarkerHits>().0, 1);
}
#[test]
fn active_texts_fill_while_shooting_and_clear_after() {
let (mut app, _, _) = test_app();
let mut sequence = test_sequence(None);
sequence.texts = vec![TextBlock::at(0.0, 0.1, "caption")];
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
let active = app.world().resource::<ActiveTexts>();
assert_eq!(active.blocks.len(), 1);
assert_eq!(active.blocks[0].block.text, "caption");
assert_eq!(active.blocks[0].alpha, 1.0);
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().resource::<ActiveTexts>().blocks.is_empty());
}
#[test]
fn active_actor_cues_fill_while_shooting_and_clear_after() {
let (mut app, _, _) = test_app();
let mut sequence = test_sequence(None);
sequence.actors =
vec![ActorTrack::entity("player").cue(ActorCue::at(0.0, 0.1, "cutscene.sleeping"))];
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
let active = app.world().resource::<ActiveActorCues>();
assert_eq!(active.cues.len(), 1);
assert_eq!(active.cues[0].cue.anim, "cutscene.sleeping");
assert!(matches!(
&active.cues[0].target,
crate::sequence::TargetRef::Entity(name) if name == "player"
));
let first_local = active.cues[0].local_time;
app.update();
let advanced = app.world().resource::<ActiveActorCues>().cues[0].local_time;
assert!(advanced > first_local, "local_time never advanced");
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().resource::<ActiveActorCues>().cues.is_empty());
}
#[test]
fn skip_clears_actor_cues_immediately() {
let (mut app, _, _) = test_app();
let mut sequence = test_sequence(None);
sequence.shots[0].duration = 10.0;
sequence.actors = vec![ActorTrack::entity("player").cue(ActorCue::at(0.0, 10.0, "sleep"))];
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert!(!app.world().resource::<ActiveActorCues>().cues.is_empty());
app.world_mut().write_message(DirectorRequest::Skip);
app.update();
assert!(app.world().resource::<ActiveActorCues>().cues.is_empty());
}
#[test]
fn pingpong_bounces_between_the_ends() {
let (mut app, _, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions {
loop_mode: LoopMode::PingPong,
..Default::default()
},
});
app.update();
let cine = app.world().resource::<DirectorState>().camera.unwrap();
let mut flipped = false;
for _ in 0..40 {
app.update();
let player = app.world().get::<SequencePlayer>(cine).unwrap();
assert!((-0.001..=0.101).contains(&player.playhead));
flipped |= player.rate < 0.0;
}
assert!(flipped, "the rate never reflected");
assert_eq!(phase(&app), DirectorPhase::Shooting);
}
#[test]
fn backward_markers_fire_on_the_mirror_rule() {
let (mut app, _, handle) = test_app();
app.init_resource::<MarkerHits>();
app.add_systems(Update, count_markers);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let cine = app.world().resource::<DirectorState>().camera.unwrap();
{
let mut entity = app.world_mut().entity_mut(cine);
let duration = entity.get::<Baked>().unwrap().compiled.duration();
entity.get_mut::<Baked>().unwrap().marker_cursor = duration;
let mut player = entity.get_mut::<SequencePlayer>().unwrap();
player.playhead = duration;
player.rate = -1.0;
}
for _ in 0..30 {
app.update();
}
assert_eq!(app.world().resource::<MarkerHits>().0, 1);
assert_eq!(phase(&app), DirectorPhase::Idle);
}
}