bevy_simple_screenshot 0.1.2

A plug-and-play screenshot library for Bevy 0.17+ with ring-buffered capture and automatic saving
//! Example: 3D Bouncing Ball with Squash & Stretch (Pixar-style)
//!
//! Run with: `cargo run --example bouncing_ball_3d`
//!
//! This example demonstrates the screenshot feature in a 3D scene:
//! - A Pixar-style bouncing ball with squash and stretch deformation
//! - A blue polygon ground plane
//! - A spotlight casting shadows
//! - A turntable camera rotating around the scene with zoom
//! - Automatic screenshots every 1.3 seconds for 15 seconds
//!
//! ## CLI Options
//!
//! ```sh
//! # Full window screenshots (default)
//! cargo run --example bouncing_ball_3d
//!
//! # Entity-only mode (crop to bouncing ball)
//! cargo run --example bouncing_ball_3d -- --entity-only
//!
//! # Entity-only with custom padding
//! cargo run --example bouncing_ball_3d -- --entity-only --padding 50
//! ```
//!
//! **Font requirement**: Download a TTF font to the project root before running:
//! ```sh
//! curl -L -o FiraMono-Medium.ttf "https://github.com/mozilla/Fira/raw/master/ttf/FiraMono-Medium.ttf"
//! ```

use bevy::prelude::*;
use bevy_simple_screenshot::prelude::*;
use clap::Parser;
use std::f32::consts::PI;

/// Configuration constants
const SCREENSHOT_INTERVAL: f32 = 1.3; // seconds between screenshots
const SCENE_DURATION: f32 = 15.0; // total scene duration in seconds
const GRAVITY: f32 = -20.0;
const BOUNCE_HEIGHT: f32 = 3.0;
const GROUND_Y: f32 = 0.0;
const BALL_RADIUS: f32 = 0.5;

/// Camera turntable configuration
const CAMERA_DISTANCE_MIN: f32 = 5.0;
const CAMERA_DISTANCE_MAX: f32 = 12.0;
const CAMERA_HEIGHT: f32 = 4.0;
const CAMERA_ROTATION_SPEED: f32 = 0.3; // radians per second
const CAMERA_ZOOM_SPEED: f32 = 0.15; // zoom oscillation speed (cycles per second)

#[derive(Parser, Debug)]
#[command(name = "bouncing_ball_3d")]
#[command(about = "3D bouncing ball with squash & stretch - screenshot demo")]
struct Args {
    /// Enable entity-only mode (crop screenshot to the bouncing ball)
    #[arg(long, short = 'e')]
    entity_only: bool,

    /// Padding around entity in pixels (only used with --entity-only)
    #[arg(long, default_value = "30")]
    padding: u32,
}

/// Resource to store screenshot mode settings
#[derive(Resource)]
struct ScreenshotMode {
    entity_only: bool,
    padding: u32,
}

fn main() {
    let args = Args::parse();

    println!("3D Bouncing Ball with Squash & Stretch");
    println!("Screenshot interval: {:.1}s", SCREENSHOT_INTERVAL);
    println!("Scene duration: {:.1}s", SCENE_DURATION);
    println!("Expected screenshots: ~{}", (SCENE_DURATION / SCREENSHOT_INTERVAL) as u32);
    println!("Mode: {}", if args.entity_only { "entity-only" } else { "full window" });
    if args.entity_only {
        println!("Padding: {}px", args.padding);
    }

    // Configure burn-in: frame only for entity-only, full info for full window
    let burn_in = if args.entity_only {
        BurnInConfig::enabled()
            .with_font("FiraMono-Medium.ttf")
            .with_position(BurnInPosition::UpperRight)
            .with_font_size(14.0)
            .with_show_frame(true)
            .with_show_key(false)
            .with_show_description(false)
    } else {
        BurnInConfig::enabled()
            .with_font("FiraMono-Medium.ttf")
            .with_position(BurnInPosition::UpperRight)
            .with_font_size(18.0)
            .with_show_frame(true)
            .with_show_key(true)
            .with_show_description(true)
    };

    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "3D Bouncing Ball - Screenshot Demo".into(),
                resolution: (800u32, 600u32).into(),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(ScreenshotBufferPlugin::with_config(
            ScreenshotConfig::default()
                .with_output_dir(".screenshots/bouncing_ball_3d")
                .with_buffer_capacity(15)
                .with_burn_in(burn_in),
        ))
        .insert_resource(ScreenshotMode {
            entity_only: args.entity_only,
            padding: args.padding,
        })
        .insert_resource(SceneTimer::default())
        .insert_resource(ScreenshotTimer::default())
        .add_systems(Startup, setup_scene)
        .add_systems(
            Update,
            (
                update_ball_physics,
                apply_squash_stretch,
                rotate_camera,
                take_screenshots,
                check_duration,
            ),
        )
        .run();
}

/// Resource to track scene elapsed time
#[derive(Resource, Default)]
struct SceneTimer {
    elapsed: f32,
}

/// Resource to track screenshot timing
#[derive(Resource)]
struct ScreenshotTimer {
    last_screenshot_time: f32,
    screenshot_count: u32,
}

impl Default for ScreenshotTimer {
    fn default() -> Self {
        Self {
            last_screenshot_time: -SCREENSHOT_INTERVAL, // Trigger first screenshot immediately
            screenshot_count: 0,
        }
    }
}

/// Component marking the bouncing ball
#[derive(Component)]
struct BouncingBall {
    velocity_y: f32,
    /// Phase of the bounce for squash/stretch (0.0 = peak, 1.0 = ground contact)
    contact_time: f32,
    is_squashing: bool,
}

/// Component for the turntable camera with zoom
#[derive(Component)]
struct TurntableCamera {
    angle: f32,
    zoom_phase: f32,
}

fn setup_scene(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    // Ground plane - blue polygon
    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(10.0, 10.0))),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::srgb(0.2, 0.4, 0.8), // Solid blue
            perceptual_roughness: 0.5,
            ..default()
        })),
        Transform::from_translation(Vec3::new(0.0, GROUND_Y, 0.0)),
    ));

    // Bouncing ball - orange/red for visibility
    let ball_mesh = meshes.add(Sphere::new(BALL_RADIUS).mesh().uv(32, 18));
    let ball_material = materials.add(StandardMaterial {
        base_color: Color::srgb(0.9, 0.4, 0.1), // Orange
        perceptual_roughness: 0.3,
        ..default()
    });

    commands.spawn((
        Mesh3d(ball_mesh),
        MeshMaterial3d(ball_material),
        Transform::from_translation(Vec3::new(0.0, BOUNCE_HEIGHT + BALL_RADIUS, 0.0)),
        BouncingBall {
            velocity_y: 0.0,
            contact_time: 0.0,
            is_squashing: false,
        },
    ));

    // Spotlight with shadows
    commands.spawn((
        SpotLight {
            intensity: 800_000.0,
            color: Color::WHITE,
            shadows_enabled: true,
            range: 20.0,
            outer_angle: PI / 4.0,
            inner_angle: PI / 6.0,
            ..default()
        },
        Transform::from_translation(Vec3::new(4.0, 8.0, 4.0))
            .looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y),
    ));

    // Ambient light for fill
    commands.insert_resource(AmbientLight {
        color: Color::srgb(0.5, 0.5, 0.6),
        brightness: 150.0,
        ..default()
    });

    // Turntable camera with zoom
    let initial_distance = (CAMERA_DISTANCE_MIN + CAMERA_DISTANCE_MAX) / 2.0;
    commands.spawn((
        Camera3d::default(),
        Transform::from_translation(Vec3::new(initial_distance, CAMERA_HEIGHT, 0.0))
            .looking_at(Vec3::new(0.0, 1.0, 0.0), Vec3::Y),
        TurntableCamera {
            angle: 0.0,
            zoom_phase: 0.0,
        },
    ));
}

/// Update ball physics with gravity and bouncing
fn update_ball_physics(time: Res<Time>, mut query: Query<(&mut Transform, &mut BouncingBall)>) {
    let dt = time.delta_secs();

    for (mut transform, mut ball) in &mut query {
        // Apply gravity
        ball.velocity_y += GRAVITY * dt;

        // Update position
        transform.translation.y += ball.velocity_y * dt;

        // Ground collision
        let ground_contact_y = GROUND_Y + BALL_RADIUS * 0.5; // Squashed ball sits lower

        if transform.translation.y <= ground_contact_y {
            transform.translation.y = ground_contact_y;

            // Bounce with energy loss
            ball.velocity_y = -ball.velocity_y * 0.85;

            // Trigger squash animation
            ball.is_squashing = true;
            ball.contact_time = 0.0;

            // Ensure minimum bounce
            if ball.velocity_y < 2.0 {
                ball.velocity_y = (2.0 * GRAVITY.abs() * BOUNCE_HEIGHT).sqrt();
            }
        }

        // Update contact time for squash animation
        if ball.is_squashing {
            ball.contact_time += dt * 8.0; // Quick squash recovery
            if ball.contact_time >= 1.0 {
                ball.is_squashing = false;
            }
        }
    }
}

/// Apply squash and stretch deformation to the ball
fn apply_squash_stretch(mut query: Query<(&mut Transform, &BouncingBall)>) {
    for (mut transform, ball) in &mut query {
        // Calculate stretch factor based on velocity (more velocity = more stretch)
        let velocity_stretch = (ball.velocity_y.abs() / 10.0).clamp(0.0, 0.4);

        // Squash when hitting ground
        let (scale_x, scale_y) = if ball.is_squashing {
            // Smooth squash animation using sine wave
            let squash_factor = (ball.contact_time * PI).sin() * 0.4;
            (
                1.0 + squash_factor,       // Wider during squash
                1.0 - squash_factor * 0.6, // Shorter during squash
            )
        } else {
            // Stretch when moving fast
            (
                1.0 - velocity_stretch * 0.3, // Narrower when stretching
                1.0 + velocity_stretch,       // Taller when stretching
            )
        };

        transform.scale = Vec3::new(scale_x, scale_y, scale_x);
    }
}

/// Rotate the turntable camera around the scene with zoom in/out
fn rotate_camera(time: Res<Time>, mut query: Query<(&mut Transform, &mut TurntableCamera)>) {
    for (mut transform, mut camera) in &mut query {
        camera.angle += CAMERA_ROTATION_SPEED * time.delta_secs();
        camera.zoom_phase += CAMERA_ZOOM_SPEED * time.delta_secs();

        // Calculate zoom distance using sine wave (smooth oscillation)
        let zoom_t = (camera.zoom_phase * PI * 2.0).sin() * 0.5 + 0.5; // 0.0 to 1.0
        let distance = CAMERA_DISTANCE_MIN + (CAMERA_DISTANCE_MAX - CAMERA_DISTANCE_MIN) * (zoom_t * 4.0);

        // Calculate new camera position on the circle with variable distance
        let x = camera.angle.cos() * distance;
        let z = camera.angle.sin() * distance;

        transform.translation = Vec3::new(x, CAMERA_HEIGHT, z);
        *transform = transform.looking_at(Vec3::new(0.0, 1.0, 0.0), Vec3::Y);
    }
}

/// Take screenshots at regular intervals
fn take_screenshots(
    time: Res<Time>,
    mode: Res<ScreenshotMode>,
    trigger: ScreenshotTrigger,
    entity_trigger: EntityScreenshotTrigger,
    ball_query: Query<Entity, With<BouncingBall>>,
    mut screenshot_timer: ResMut<ScreenshotTimer>,
) {
    let elapsed = time.elapsed_secs();

    if elapsed - screenshot_timer.last_screenshot_time >= SCREENSHOT_INTERVAL {
        screenshot_timer.last_screenshot_time = elapsed;
        screenshot_timer.screenshot_count += 1;

        let description = format!("capture_{}", screenshot_timer.screenshot_count);

        if mode.entity_only {
            // Entity-focused screenshot (cropped to ball bounds + padding)
            for ball_entity in &ball_query {
                let settings = EntityScreenshotSettings::default().with_padding(mode.padding);
                screenshot_entity!(&entity_trigger, ball_entity, "ball", &description, settings);
                info!(
                    "Entity screenshot #{} taken at {:.1}s (padding={}px)",
                    screenshot_timer.screenshot_count, elapsed, mode.padding
                );
            }
        } else {
            // Full window screenshot
            screenshot!(&trigger, "bouncing_ball", &description);
            info!(
                "Screenshot #{} taken at {:.1}s",
                screenshot_timer.screenshot_count, elapsed
            );
        }
    }
}

/// Check if scene duration has elapsed and exit
#[allow(deprecated)]
fn check_duration(
    time: Res<Time>,
    mode: Res<ScreenshotMode>,
    mut scene_timer: ResMut<SceneTimer>,
    screenshot_timer: Res<ScreenshotTimer>,
    mut exit: bevy::ecs::event::EventWriter<AppExit>,
) {
    scene_timer.elapsed = time.elapsed_secs();

    if scene_timer.elapsed >= SCENE_DURATION {
        println!("\n========================================");
        println!("Scene completed after {:.1} seconds", SCENE_DURATION);
        println!(
            "Total screenshots taken: {}",
            screenshot_timer.screenshot_count
        );
        println!("Mode: {}", if mode.entity_only { "entity-only" } else { "full window" });
        println!("Screenshots saved to: .screenshots/bouncing_ball_3d/");
        println!("========================================\n");
        exit.write(AppExit::Success);
    }
}