cloudiful-bevy-camera 0.2.2

Reusable camera switching core for Bevy.
Documentation
use crate::components::SwitchableCamera;
use crate::events::{CameraSwitched, SwitchCameraRequest};
use bevy::camera::Camera;
use bevy::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CameraCandidate {
    entity: Entity,
    switchable: SwitchableCamera,
    is_active: bool,
}

pub(crate) fn apply_switch_camera_requests(
    mut requests: MessageReader<SwitchCameraRequest>,
    mut switched_events: MessageWriter<CameraSwitched>,
    mut cameras: ParamSet<(
        Query<(Entity, &SwitchableCamera, &Camera), With<SwitchableCamera>>,
        Query<(Entity, &mut Camera), With<SwitchableCamera>>,
    )>,
) {
    for request in requests.read().copied() {
        let ordered_cameras = ordered_candidates(&cameras.p0());
        let Some(target_camera) = resolve_target(&ordered_cameras, request) else {
            continue;
        };

        let previous = previous_active_camera(&ordered_cameras);
        if previous == Some(target_camera) && active_camera_count(&ordered_cameras) == 1 {
            continue;
        }

        let changed = activate_camera(&mut cameras.p1(), target_camera);
        if !changed {
            continue;
        }

        switched_events.write(CameraSwitched {
            previous,
            current: target_camera,
        });
    }
}

fn ordered_candidates(
    cameras: &Query<(Entity, &SwitchableCamera, &Camera), With<SwitchableCamera>>,
) -> Vec<CameraCandidate> {
    let mut ordered_cameras = cameras
        .iter()
        .map(|(entity, switchable, camera)| CameraCandidate {
            entity,
            switchable: *switchable,
            is_active: camera.is_active,
        })
        .collect::<Vec<_>>();

    ordered_cameras.sort_unstable_by_key(|candidate| {
        (
            candidate.switchable.order_key,
            candidate.switchable.slot.unwrap_or(u8::MAX),
            candidate.entity.index(),
        )
    });

    ordered_cameras
}

fn resolve_target(
    ordered_cameras: &[CameraCandidate],
    request: SwitchCameraRequest,
) -> Option<Entity> {
    match request {
        SwitchCameraRequest::ToEntity(entity) => ordered_cameras
            .iter()
            .find(|candidate| candidate.entity == entity)
            .map(|candidate| candidate.entity),
        SwitchCameraRequest::ToSlot(slot) => ordered_cameras
            .iter()
            .find(|candidate| candidate.switchable.slot == Some(slot))
            .map(|candidate| candidate.entity),
        SwitchCameraRequest::CycleNext => cycle_target(ordered_cameras, 1),
        SwitchCameraRequest::CyclePrev => cycle_target(ordered_cameras, -1),
    }
}

fn cycle_target(ordered_cameras: &[CameraCandidate], direction: isize) -> Option<Entity> {
    if ordered_cameras.is_empty() {
        return None;
    }

    let current_index = ordered_cameras
        .iter()
        .position(|candidate| candidate.is_active)
        .unwrap_or(0);

    if active_camera_count(ordered_cameras) == 0 {
        return Some(ordered_cameras[0].entity);
    }

    let len = ordered_cameras.len() as isize;
    let target_index = (current_index as isize + direction).rem_euclid(len) as usize;

    Some(ordered_cameras[target_index].entity)
}

fn active_camera_count(ordered_cameras: &[CameraCandidate]) -> usize {
    ordered_cameras
        .iter()
        .filter(|candidate| candidate.is_active)
        .count()
}

fn previous_active_camera(ordered_cameras: &[CameraCandidate]) -> Option<Entity> {
    let mut active_cameras = ordered_cameras
        .iter()
        .filter(|candidate| candidate.is_active)
        .map(|candidate| candidate.entity);
    let first = active_cameras.next()?;

    if active_cameras.next().is_some() {
        return None;
    }

    Some(first)
}

fn activate_camera(
    cameras: &mut Query<(Entity, &mut Camera), With<SwitchableCamera>>,
    target_camera: Entity,
) -> bool {
    let mut changed = false;

    for (entity, mut camera) in cameras.iter_mut() {
        let should_be_active = entity == target_camera;
        if camera.is_active != should_be_active {
            camera.is_active = should_be_active;
            changed = true;
        }
    }

    changed
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugin::CameraSwitchPlugin;
    use bevy::ecs::message::Messages;

    fn test_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(CameraSwitchPlugin);
        app
    }

    fn spawn_switchable_camera(
        app: &mut App,
        slot: Option<u8>,
        order_key: i32,
        is_active: bool,
    ) -> Entity {
        app.world_mut()
            .spawn((
                Camera {
                    is_active,
                    ..default()
                },
                SwitchableCamera { slot, order_key },
            ))
            .id()
    }

    fn active_cameras(app: &mut App) -> Vec<Entity> {
        let world = app.world_mut();
        let mut query = world.query::<(Entity, &Camera, &SwitchableCamera)>();
        let mut active_cameras = query
            .iter(world)
            .filter_map(|(entity, camera, _)| camera.is_active.then_some(entity))
            .collect::<Vec<_>>();
        active_cameras.sort_unstable_by_key(|entity| entity.index());
        active_cameras
    }

    fn send_request(app: &mut App, request: SwitchCameraRequest) {
        app.world_mut().write_message(request);
        app.update();
    }

    fn drain_switched_events(app: &mut App) -> Vec<CameraSwitched> {
        app.world_mut()
            .resource_mut::<Messages<CameraSwitched>>()
            .drain()
            .collect()
    }

    #[test]
    fn to_slot_activates_target_and_deactivates_others() {
        let mut app = test_app();
        let first = spawn_switchable_camera(&mut app, Some(1), 20, true);
        let second = spawn_switchable_camera(&mut app, Some(2), 10, false);

        send_request(&mut app, SwitchCameraRequest::ToSlot(2));

        assert_eq!(active_cameras(&mut app), vec![second]);
        assert_eq!(
            drain_switched_events(&mut app),
            vec![CameraSwitched {
                previous: Some(first),
                current: second,
            }]
        );
    }

    #[test]
    fn cycle_next_uses_stable_order() {
        let mut app = test_app();
        let third = spawn_switchable_camera(&mut app, Some(3), 30, false);
        let first = spawn_switchable_camera(&mut app, Some(1), 10, true);
        let second = spawn_switchable_camera(&mut app, Some(2), 20, false);

        send_request(&mut app, SwitchCameraRequest::CycleNext);

        assert_eq!(active_cameras(&mut app), vec![second]);
        assert_eq!(
            drain_switched_events(&mut app),
            vec![CameraSwitched {
                previous: Some(first),
                current: second,
            }]
        );

        send_request(&mut app, SwitchCameraRequest::CycleNext);

        assert_eq!(active_cameras(&mut app), vec![third]);
    }

    #[test]
    fn cycle_prev_uses_stable_order() {
        let mut app = test_app();
        let third = spawn_switchable_camera(&mut app, Some(3), 30, true);
        let first = spawn_switchable_camera(&mut app, Some(1), 10, false);
        let second = spawn_switchable_camera(&mut app, Some(2), 20, false);

        send_request(&mut app, SwitchCameraRequest::CyclePrev);

        assert_eq!(active_cameras(&mut app), vec![second]);
        assert_eq!(
            drain_switched_events(&mut app),
            vec![CameraSwitched {
                previous: Some(third),
                current: second,
            }]
        );

        send_request(&mut app, SwitchCameraRequest::CyclePrev);

        assert_eq!(active_cameras(&mut app), vec![first]);
    }

    #[test]
    fn cycle_without_active_camera_selects_first_candidate() {
        let mut app = test_app();
        let first = spawn_switchable_camera(&mut app, Some(2), 10, false);
        spawn_switchable_camera(&mut app, Some(1), 20, false);

        send_request(&mut app, SwitchCameraRequest::CyclePrev);

        assert_eq!(active_cameras(&mut app), vec![first]);
        assert_eq!(
            drain_switched_events(&mut app),
            vec![CameraSwitched {
                previous: None,
                current: first,
            }]
        );
    }

    #[test]
    fn to_missing_slot_keeps_current_state() {
        let mut app = test_app();
        let first = spawn_switchable_camera(&mut app, Some(1), 10, true);
        spawn_switchable_camera(&mut app, Some(2), 20, false);

        send_request(&mut app, SwitchCameraRequest::ToSlot(9));

        assert_eq!(active_cameras(&mut app), vec![first]);
        assert!(drain_switched_events(&mut app).is_empty());
    }

    #[test]
    fn to_entity_ignores_non_switchable_or_non_camera_entities() {
        let mut app = test_app();
        let active = spawn_switchable_camera(&mut app, Some(1), 10, true);
        let plain_camera = app.world_mut().spawn(Camera::default()).id();
        let plain_switchable = app
            .world_mut()
            .spawn((SwitchableCamera::default(), Transform::default()))
            .id();

        send_request(&mut app, SwitchCameraRequest::ToEntity(plain_camera));
        send_request(&mut app, SwitchCameraRequest::ToEntity(plain_switchable));

        assert_eq!(active_cameras(&mut app), vec![active]);
        assert!(drain_switched_events(&mut app).is_empty());
    }

    #[test]
    fn multiple_active_cameras_collapse_on_next_valid_switch() {
        let mut app = test_app();
        spawn_switchable_camera(&mut app, Some(1), 10, true);
        spawn_switchable_camera(&mut app, Some(2), 20, true);
        let target = spawn_switchable_camera(&mut app, Some(3), 30, false);

        send_request(&mut app, SwitchCameraRequest::ToSlot(3));

        assert_eq!(active_cameras(&mut app), vec![target]);
        assert_eq!(
            drain_switched_events(&mut app),
            vec![CameraSwitched {
                previous: None,
                current: target,
            }]
        );
    }

    #[test]
    fn switching_to_same_unique_active_camera_emits_no_event() {
        let mut app = test_app();
        let first = spawn_switchable_camera(&mut app, Some(1), 10, true);
        spawn_switchable_camera(&mut app, Some(2), 20, false);

        send_request(&mut app, SwitchCameraRequest::ToEntity(first));

        assert_eq!(active_cameras(&mut app), vec![first]);
        assert!(drain_switched_events(&mut app).is_empty());
    }
}