From 663858d7ff41c36c54854f887b1c73839ad86624 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Fri, 29 Nov 2024 21:52:33 -0500 Subject: [PATCH] Improve docs, fix data samples, fix examples, use PlaybackWindow to control playback --- data/app_exit.ron | 2 +- data/hello_world.ron | 579 ++++++------ examples/gamepad.rs | 1220 ++++++++++++------------- examples/input_capture.rs | 12 +- examples/input_playback.rs | 269 +++--- examples/playback_serialized_input.rs | 43 +- examples/serialize_captured_input.rs | 22 +- examples/useless_machine.rs | 32 +- src/input_capture.rs | 10 +- src/input_playback.rs | 911 +++++++++--------- 10 files changed, 1588 insertions(+), 1512 deletions(-) diff --git a/data/app_exit.ron b/data/app_exit.ron index b843f5f..da5cac9 100644 --- a/data/app_exit.ron +++ b/data/app_exit.ron @@ -1,7 +1,7 @@ ( events: [ ( - frame: (71), + frame: 71, time_since_startup: ( secs: 1, nanos: 592977800, diff --git a/data/hello_world.ron b/data/hello_world.ron index d721bd7..bf58ae8 100644 --- a/data/hello_world.ron +++ b/data/hello_world.ron @@ -1,269 +1,312 @@ -( - events: [ - ( - frame: (116), - time_since_startup: ( - secs: 2, - nanos: 380259200, - ), - input_event: Keyboard(( - scan_code: 35, - key_code: Some(H), - state: Pressed, - )), - ), - ( - frame: (120), - time_since_startup: ( - secs: 2, - nanos: 446911200, - ), - input_event: Keyboard(( - scan_code: 35, - key_code: Some(H), - state: Released, - )), - ), - ( - frame: (177), - time_since_startup: ( - secs: 3, - nanos: 396952300, - ), - input_event: Keyboard(( - scan_code: 18, - key_code: Some(E), - state: Pressed, - )), - ), - ( - frame: (180), - time_since_startup: ( - secs: 3, - nanos: 446934100, - ), - input_event: Keyboard(( - scan_code: 18, - key_code: Some(E), - state: Released, - )), - ), - ( - frame: (220), - time_since_startup: ( - secs: 4, - nanos: 113641400, - ), - input_event: Keyboard(( - scan_code: 38, - key_code: Some(L), - state: Pressed, - )), - ), - ( - frame: (224), - time_since_startup: ( - secs: 4, - nanos: 180281900, - ), - input_event: Keyboard(( - scan_code: 38, - key_code: Some(L), - state: Released, - )), - ), - ( - frame: (249), - time_since_startup: ( - secs: 4, - nanos: 596947200, - ), - input_event: Keyboard(( - scan_code: 38, - key_code: Some(L), - state: Pressed, - )), - ), - ( - frame: (255), - time_since_startup: ( - secs: 4, - nanos: 697010200, - ), - input_event: Keyboard(( - scan_code: 38, - key_code: Some(L), - state: Released, - )), - ), - ( - frame: (285), - time_since_startup: ( - secs: 5, - nanos: 197035600, - ), - input_event: Keyboard(( - scan_code: 24, - key_code: Some(O), - state: Pressed, - )), - ), - ( - frame: (290), - time_since_startup: ( - secs: 5, - nanos: 280284800, - ), - input_event: Keyboard(( - scan_code: 24, - key_code: Some(O), - state: Released, - )), - ), - ( - frame: (316), - time_since_startup: ( - secs: 5, - nanos: 713653300, - ), - input_event: Keyboard(( - scan_code: 57, - key_code: Some(Space), - state: Pressed, - )), - ), - ( - frame: (321), - time_since_startup: ( - secs: 5, - nanos: 796981700, - ), - input_event: Keyboard(( - scan_code: 57, - key_code: Some(Space), - state: Released, - )), - ), - ( - frame: (346), - time_since_startup: ( - secs: 6, - nanos: 213645600, - ), - input_event: Keyboard(( - scan_code: 17, - key_code: Some(W), - state: Pressed, - )), - ), - ( - frame: (349), - time_since_startup: ( - secs: 6, - nanos: 263658900, - ), - input_event: Keyboard(( - scan_code: 17, - key_code: Some(W), - state: Released, - )), - ), - ( - frame: (365), - time_since_startup: ( - secs: 6, - nanos: 530358800, - ), - input_event: Keyboard(( - scan_code: 24, - key_code: Some(O), - state: Pressed, - )), - ), - ( - frame: (369), - time_since_startup: ( - secs: 6, - nanos: 597034800, - ), - input_event: Keyboard(( - scan_code: 24, - key_code: Some(O), - state: Released, - )), - ), - ( - frame: (380), - time_since_startup: ( - secs: 6, - nanos: 780319600, - ), - input_event: Keyboard(( - scan_code: 19, - key_code: Some(R), - state: Pressed, - )), - ), - ( - frame: (384), - time_since_startup: ( - secs: 6, - nanos: 847000600, - ), - input_event: Keyboard(( - scan_code: 19, - key_code: Some(R), - state: Released, - )), - ), - ( - frame: (404), - time_since_startup: ( - secs: 7, - nanos: 180385800, - ), - input_event: Keyboard(( - scan_code: 38, - key_code: Some(L), - state: Pressed, - )), - ), - ( - frame: (410), - time_since_startup: ( - secs: 7, - nanos: 280361100, - ), - input_event: Keyboard(( - scan_code: 38, - key_code: Some(L), - state: Released, - )), - ), - ( - frame: (413), - time_since_startup: ( - secs: 7, - nanos: 330342400, - ), - input_event: Keyboard(( - scan_code: 32, - key_code: Some(D), - state: Pressed, - )), - ), - ( - frame: (418), - time_since_startup: ( - secs: 7, - nanos: 413715800, - ), - input_event: Keyboard(( - scan_code: 32, - key_code: Some(D), - state: Released, - )), - ), - ], - cursor: 0, +( + events: [ + ( + frame: 72, + time_since_startup: ( + secs: 1, + nanos: 171552588, + ), + input_event: Keyboard(( + key_code: KeyH, + logical_key: Character("h"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 76, + time_since_startup: ( + secs: 1, + nanos: 237682433, + ), + input_event: Keyboard(( + key_code: KeyE, + logical_key: Character("e"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 79, + time_since_startup: ( + secs: 1, + nanos: 287808619, + ), + input_event: Keyboard(( + key_code: KeyH, + logical_key: Character("h"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 83, + time_since_startup: ( + secs: 1, + nanos: 354264132, + ), + input_event: Keyboard(( + key_code: KeyE, + logical_key: Character("e"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 84, + time_since_startup: ( + secs: 1, + nanos: 371552096, + ), + input_event: Keyboard(( + key_code: KeyL, + logical_key: Character("l"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 87, + time_since_startup: ( + secs: 1, + nanos: 420491538, + ), + input_event: Keyboard(( + key_code: KeyL, + logical_key: Character("l"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 91, + time_since_startup: ( + secs: 1, + nanos: 487469805, + ), + input_event: Keyboard(( + key_code: KeyL, + logical_key: Character("l"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 97, + time_since_startup: ( + secs: 1, + nanos: 587663589, + ), + input_event: Keyboard(( + key_code: KeyL, + logical_key: Character("l"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 102, + time_since_startup: ( + secs: 1, + nanos: 670926111, + ), + input_event: Keyboard(( + key_code: KeyO, + logical_key: Character("o"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 108, + time_since_startup: ( + secs: 1, + nanos: 770864645, + ), + input_event: Keyboard(( + key_code: KeyO, + logical_key: Character("o"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 126, + time_since_startup: ( + secs: 2, + nanos: 70705952, + ), + input_event: Keyboard(( + key_code: Space, + logical_key: Space, + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 130, + time_since_startup: ( + secs: 2, + nanos: 137360743, + ), + input_event: Keyboard(( + key_code: Space, + logical_key: Space, + state: Released, + window: 4294967296, + )), + ), + ( + frame: 131, + time_since_startup: ( + secs: 2, + nanos: 154046279, + ), + input_event: Keyboard(( + key_code: KeyW, + logical_key: Character("w"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 135, + time_since_startup: ( + secs: 2, + nanos: 220650318, + ), + input_event: Keyboard(( + key_code: KeyW, + logical_key: Character("w"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 140, + time_since_startup: ( + secs: 2, + nanos: 303982817, + ), + input_event: Keyboard(( + key_code: KeyO, + logical_key: Character("o"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 142, + time_since_startup: ( + secs: 2, + nanos: 337671183, + ), + input_event: Keyboard(( + key_code: KeyR, + logical_key: Character("r"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 145, + time_since_startup: ( + secs: 2, + nanos: 386928375, + ), + input_event: Keyboard(( + key_code: KeyO, + logical_key: Character("o"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 146, + time_since_startup: ( + secs: 2, + nanos: 402879424, + ), + input_event: Keyboard(( + key_code: KeyR, + logical_key: Character("r"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 150, + time_since_startup: ( + secs: 2, + nanos: 470161663, + ), + input_event: Keyboard(( + key_code: KeyL, + logical_key: Character("l"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 151, + time_since_startup: ( + secs: 2, + nanos: 486047197, + ), + input_event: Keyboard(( + key_code: KeyD, + logical_key: Character("d"), + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 154, + time_since_startup: ( + secs: 2, + nanos: 536300612, + ), + input_event: Keyboard(( + key_code: KeyL, + logical_key: Character("l"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 156, + time_since_startup: ( + secs: 2, + nanos: 569849084, + ), + input_event: Keyboard(( + key_code: KeyD, + logical_key: Character("d"), + state: Released, + window: 4294967296, + )), + ), + ( + frame: 161, + time_since_startup: ( + secs: 2, + nanos: 653288844, + ), + input_event: Keyboard(( + key_code: AltLeft, + logical_key: Alt, + state: Pressed, + window: 4294967296, + )), + ), + ( + frame: 170, + time_since_startup: ( + secs: 2, + nanos: 802678637, + ), + input_event: AppExit, + ), + ], + cursor: 0, ) \ No newline at end of file diff --git a/examples/gamepad.rs b/examples/gamepad.rs index 8942f38..9cf95dc 100644 --- a/examples/gamepad.rs +++ b/examples/gamepad.rs @@ -1,610 +1,610 @@ -//! Demonstrates input capture and playback of gamepad inputs -//! -//! This example is modified from https://github.com/bevyengine/bevy/blob/main/examples/tools/gamepad_viewer.rs, -//! which is used here under the MIT License <3 - -//! Shows a visualization of gamepad buttons, sticks, and triggers - -use bevy::prelude::*; -use leafwing_input_playback::{ - input_capture::{InputCapturePlugin, InputModesCaptured}, - input_playback::{InputPlaybackPlugin, PlaybackStrategy}, - timestamped_input::TimestampedInputs, -}; - -fn main() { - use gamepad_viewer_example::*; - - App::new() - // This plugin contains all the code from the original example - .add_plugins(( - GamepadViewerExample, - InputCapturePlugin, - InputPlaybackPlugin, - )) - // Disable all input capture and playback to start - .insert_resource(InputModesCaptured::DISABLE_ALL) - .insert_resource(PlaybackStrategy::Paused) - // Toggle between playback and capture using Space - .insert_resource(InputStrategy::Playback) - .add_systems(Update, toggle_capture_vs_playback) - .run(); -} - -#[derive(Resource, PartialEq)] -enum InputStrategy { - Capture, - Playback, -} - -fn toggle_capture_vs_playback( - mut input_modes: ResMut, - mut playback_strategy: ResMut, - keyboard_input: Res>, - mut timestamped_input: ResMut, - mut input_strategy: ResMut, -) { - if keyboard_input.just_pressed(KeyCode::Space) { - *input_strategy = match *input_strategy { - InputStrategy::Capture => { - // Disable input capture - *input_modes = InputModesCaptured::DISABLE_ALL; - // Enable input playback - *playback_strategy = if let Some((start, end)) = - // Play back all recorded inputs at the same rate they were input - timestamped_input.frame_range() - { - PlaybackStrategy::FrameRangeOnce(start, end) - } else { - // Do not play back events if none were recorded - PlaybackStrategy::Paused - }; - - info!("Now playing back input."); - InputStrategy::Playback - } - InputStrategy::Playback => { - // Enable input capture - *input_modes = InputModesCaptured::ENABLE_ALL; - // Disable input playback - *playback_strategy = PlaybackStrategy::Paused; - - // Reset all input data, starting a new recording - *timestamped_input = TimestampedInputs::default(); - - info!("Now capturing input."); - InputStrategy::Capture - } - }; - } -} - -mod gamepad_viewer_example { - /// This is the main function from the example adapted from - /// https://github.com/bevyengine/bevy/blob/main/examples/tools/gamepad_viewer.rs - pub struct GamepadViewerExample; - - impl Plugin for GamepadViewerExample { - fn build(&self, app: &mut App) { - app.add_plugins(DefaultPlugins) - .init_resource::() - .init_resource::() - .init_resource::() - .add_systems( - Startup, - (setup, setup_sticks, setup_triggers, setup_connected), - ) - .add_systems( - Update, - ( - update_buttons, - update_button_values, - update_axes, - update_connected, - ), - ); - } - } - - use std::f32::consts::PI; - - use bevy::{ - color::palettes, - input::gamepad::{GamepadButton, GamepadButtonChangedEvent, GamepadEvent, GamepadSettings}, - prelude::*, - sprite::{MaterialMesh2dBundle, Mesh2dHandle}, - }; - - const BUTTON_RADIUS: f32 = 25.; - const BUTTON_CLUSTER_RADIUS: f32 = 50.; - const START_SIZE: Vec2 = Vec2::new(30., 15.); - const TRIGGER_SIZE: Vec2 = Vec2::new(70., 20.); - const STICK_BOUNDS_SIZE: f32 = 100.; - - const BUTTONS_X: f32 = 150.; - const BUTTONS_Y: f32 = 80.; - const STICKS_X: f32 = 150.; - const STICKS_Y: f32 = -135.; - - const NORMAL_BUTTON_COLOR: Color = Color::srgb(0.2, 0.2, 0.2); - const ACTIVE_BUTTON_COLOR: Color = Color::Srgba(palettes::css::PURPLE); - const LIVE_COLOR: Color = Color::srgb(0.4, 0.4, 0.4); - const DEAD_COLOR: Color = Color::srgb(0.3, 0.3, 0.3); - const EXTENT_COLOR: Color = Color::srgb(0.3, 0.3, 0.3); - const TEXT_COLOR: Color = Color::WHITE; - - #[derive(Component, Deref)] - struct ReactTo(GamepadButtonType); - #[derive(Component)] - struct MoveWithAxes { - x_axis: GamepadAxisType, - y_axis: GamepadAxisType, - scale: f32, - } - #[derive(Component)] - struct TextWithAxes { - x_axis: GamepadAxisType, - y_axis: GamepadAxisType, - } - #[derive(Component, Deref)] - struct TextWithButtonValue(GamepadButtonType); - - #[derive(Component)] - struct ConnectedGamepadsText; - - #[derive(Resource)] - struct ButtonMaterials { - normal: Handle, - active: Handle, - } - - impl FromWorld for ButtonMaterials { - fn from_world(world: &mut World) -> Self { - let mut materials = world.resource_mut::>(); - Self { - normal: materials.add(ColorMaterial::from(NORMAL_BUTTON_COLOR)), - active: materials.add(ColorMaterial::from(ACTIVE_BUTTON_COLOR)), - } - } - } - #[derive(Resource)] - struct ButtonMeshes { - circle: Mesh2dHandle, - triangle: Mesh2dHandle, - start_pause: Mesh2dHandle, - trigger: Mesh2dHandle, - } - - impl FromWorld for ButtonMeshes { - fn from_world(world: &mut World) -> Self { - let mut meshes = world.resource_mut::>(); - Self { - circle: meshes.add(Circle::new(BUTTON_RADIUS).mesh()).into(), - triangle: meshes - .add(RegularPolygon::new(BUTTON_RADIUS, 3).mesh()) - .into(), - start_pause: meshes - .add(Rectangle::new(START_SIZE.x, START_SIZE.y).mesh()) - .into(), - trigger: meshes - .add(Rectangle::new(TRIGGER_SIZE.x, TRIGGER_SIZE.y).mesh()) - .into(), - } - } - } - #[derive(Resource, Deref)] - struct FontHandle(Handle); - impl FromWorld for FontHandle { - fn from_world(world: &mut World) -> Self { - let asset_server = world.resource::(); - Self(asset_server.load("fonts/FiraSans-Bold.ttf")) - } - } - - fn setup(mut commands: Commands, meshes: Res, materials: Res) { - commands.spawn(Camera2dBundle::default()); - - // Buttons - - commands - .spawn(SpatialBundle { - transform: Transform::from_xyz(BUTTONS_X, BUTTONS_Y, 0.), - ..default() - }) - .with_children(|parent| { - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.circle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(0., BUTTON_CLUSTER_RADIUS, 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::North)); - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.circle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(0., -BUTTON_CLUSTER_RADIUS, 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::South)); - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.circle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(-BUTTON_CLUSTER_RADIUS, 0., 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::West)); - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.circle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(BUTTON_CLUSTER_RADIUS, 0., 0.), - - ..default() - }) - .insert(ReactTo(GamepadButtonType::East)); - }); - - // Start and Pause - - commands - .spawn(MaterialMesh2dBundle { - mesh: meshes.start_pause.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(-30., BUTTONS_Y, 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::Select)); - - commands - .spawn(MaterialMesh2dBundle { - mesh: meshes.start_pause.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(30., BUTTONS_Y, 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::Start)); - - // D-Pad - - commands - .spawn(SpatialBundle { - transform: Transform::from_xyz(-BUTTONS_X, BUTTONS_Y, 0.), - ..default() - }) - .with_children(|parent| { - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.triangle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(0., BUTTON_CLUSTER_RADIUS, 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::DPadUp)); - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.triangle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(0., -BUTTON_CLUSTER_RADIUS, 0.) - .with_rotation(Quat::from_rotation_z(PI)), - ..default() - }) - .insert(ReactTo(GamepadButtonType::DPadDown)); - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.triangle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(-BUTTON_CLUSTER_RADIUS, 0., 0.) - .with_rotation(Quat::from_rotation_z(PI / 2.)), - ..default() - }) - .insert(ReactTo(GamepadButtonType::DPadLeft)); - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.triangle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(BUTTON_CLUSTER_RADIUS, 0., 0.) - .with_rotation(Quat::from_rotation_z(-PI / 2.)), - ..default() - }) - .insert(ReactTo(GamepadButtonType::DPadRight)); - }); - - // Triggers - - commands - .spawn(MaterialMesh2dBundle { - mesh: meshes.trigger.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(-BUTTONS_X, BUTTONS_Y + 115., 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::LeftTrigger)); - - commands - .spawn(MaterialMesh2dBundle { - mesh: meshes.trigger.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(BUTTONS_X, BUTTONS_Y + 115., 0.), - ..default() - }) - .insert(ReactTo(GamepadButtonType::RightTrigger)); - } - - fn setup_sticks( - mut commands: Commands, - meshes: Res, - materials: Res, - gamepad_settings: Res, - font: Res, - ) { - let dead_upper = - STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.deadzone_upperbound(); - let dead_lower = - STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.deadzone_lowerbound(); - let dead_size = dead_lower.abs() + dead_upper.abs(); - let dead_mid = (dead_lower + dead_upper) / 2.0; - - let live_upper = - STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.livezone_upperbound(); - let live_lower = - STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.livezone_lowerbound(); - let live_size = live_lower.abs() + live_upper.abs(); - let live_mid = (live_lower + live_upper) / 2.0; - - let mut spawn_stick = |x_pos, y_pos, x_axis, y_axis, button| { - commands - .spawn(SpatialBundle { - transform: Transform::from_xyz(x_pos, y_pos, 0.), - ..default() - }) - .with_children(|parent| { - // full extent - parent.spawn(SpriteBundle { - sprite: Sprite { - custom_size: Some(Vec2::splat(STICK_BOUNDS_SIZE * 2.)), - color: EXTENT_COLOR, - ..default() - }, - ..default() - }); - // live zone - parent.spawn(SpriteBundle { - transform: Transform::from_xyz(live_mid, live_mid, 2.), - sprite: Sprite { - custom_size: Some(Vec2::new(live_size, live_size)), - color: LIVE_COLOR, - ..default() - }, - ..default() - }); - // dead zone - parent.spawn(SpriteBundle { - transform: Transform::from_xyz(dead_mid, dead_mid, 3.), - sprite: Sprite { - custom_size: Some(Vec2::new(dead_size, dead_size)), - color: DEAD_COLOR, - ..default() - }, - ..default() - }); - // text - let style = TextStyle { - font_size: 16., - color: TEXT_COLOR, - font: font.clone(), - }; - parent - .spawn(Text2dBundle { - transform: Transform::from_xyz(0., STICK_BOUNDS_SIZE + 2., 4.), - text: Text::from_sections([ - TextSection { - value: format!("{:.3}", 0.), - style: style.clone(), - }, - TextSection { - value: ", ".to_string(), - style: style.clone(), - }, - TextSection { - value: format!("{:.3}", 0.), - style, - }, - ]) - .with_justify(JustifyText::Center), - ..default() - }) - .insert(TextWithAxes { x_axis, y_axis }); - // cursor - parent - .spawn(MaterialMesh2dBundle { - mesh: meshes.circle.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(0., 0., 5.) - .with_scale(Vec2::splat(0.2).extend(1.)), - ..default() - }) - .insert(MoveWithAxes { - x_axis, - y_axis, - scale: STICK_BOUNDS_SIZE, - }) - .insert(ReactTo(button)); - }); - }; - - spawn_stick( - -STICKS_X, - STICKS_Y, - GamepadAxisType::LeftStickX, - GamepadAxisType::LeftStickY, - GamepadButtonType::LeftThumb, - ); - spawn_stick( - STICKS_X, - STICKS_Y, - GamepadAxisType::RightStickX, - GamepadAxisType::RightStickY, - GamepadButtonType::RightThumb, - ); - } - - fn setup_triggers( - mut commands: Commands, - meshes: Res, - materials: Res, - font: Res, - ) { - let mut spawn_trigger = |x, y, button_type| { - commands - .spawn(MaterialMesh2dBundle { - mesh: meshes.trigger.clone(), - material: materials.normal.clone(), - transform: Transform::from_xyz(x, y, 0.), - ..default() - }) - .insert(ReactTo(button_type)) - .with_children(|parent| { - parent - .spawn(Text2dBundle { - transform: Transform::from_xyz(0., 0., 1.), - text: Text::from_section( - format!("{:.3}", 0.), - TextStyle { - font: font.clone(), - font_size: 16., - color: TEXT_COLOR, - }, - ) - .with_justify(JustifyText::Center), - ..default() - }) - .insert(TextWithButtonValue(button_type)); - }); - }; - - spawn_trigger( - -BUTTONS_X, - BUTTONS_Y + 145., - GamepadButtonType::LeftTrigger2, - ); - spawn_trigger( - BUTTONS_X, - BUTTONS_Y + 145., - GamepadButtonType::RightTrigger2, - ); - } - - fn setup_connected(mut commands: Commands, font: Res) { - let style = TextStyle { - color: TEXT_COLOR, - font_size: 30., - font: font.clone(), - }; - commands - .spawn(TextBundle::from_sections([ - TextSection { - value: "Connected Gamepads\n".to_string(), - style: style.clone(), - }, - TextSection { - value: "None".to_string(), - style, - }, - ])) - .insert(ConnectedGamepadsText); - } - - fn update_buttons( - gamepads: Res, - button_inputs: Res>, - materials: Res, - mut query: Query<(&mut Handle, &ReactTo)>, - ) { - for gamepad in gamepads.iter() { - for (mut handle, react_to) in query.iter_mut() { - if button_inputs.just_pressed(GamepadButton::new(gamepad, **react_to)) { - *handle = materials.active.clone(); - } - if button_inputs.just_released(GamepadButton::new(gamepad, **react_to)) { - *handle = materials.normal.clone(); - } - } - } - } - - fn update_button_values( - mut events: EventReader, - mut query: Query<(&mut Text, &TextWithButtonValue)>, - ) { - for event in events.read() { - if let GamepadEvent::Button(GamepadButtonChangedEvent { - gamepad: _, - button_type, - value, - }) = event - { - for (mut text, text_with_button_value) in query.iter_mut() { - if *button_type == **text_with_button_value { - text.sections[0].value = format!("{:.3}", value); - } - } - } - } - } - - fn update_axes( - mut events: EventReader, - mut query: Query<(&mut Transform, &MoveWithAxes)>, - mut text_query: Query<(&mut Text, &TextWithAxes)>, - ) { - for event in events.read() { - if let GamepadEvent::Axis(axis_changed_event) = event { - let axis_type = axis_changed_event.axis_type; - let value = axis_changed_event.value; - - for (mut transform, move_with) in query.iter_mut() { - if axis_type == move_with.x_axis { - transform.translation.x = value * move_with.scale; - } - if axis_type == move_with.y_axis { - transform.translation.y = value * move_with.scale; - } - } - for (mut text, text_with_axes) in text_query.iter_mut() { - if axis_type == text_with_axes.x_axis { - text.sections[0].value = format!("{:.3}", value); - } - if axis_type == text_with_axes.y_axis { - text.sections[2].value = format!("{:.3}", value); - } - } - } - } - } - - fn update_connected( - gamepads: Res, - mut query: Query<&mut Text, With>, - ) { - if !gamepads.is_changed() { - return; - } - - let mut text = query.single_mut(); - - let formatted = gamepads - .iter() - .map(|g| format!("{:?}", g)) - .collect::>() - .join("\n"); - - text.sections[1].value = if !formatted.is_empty() { - formatted - } else { - "None".to_string() - } - } -} +//! Demonstrates input capture and playback of gamepad inputs +//! +//! This example is modified from https://github.com/bevyengine/bevy/blob/main/examples/tools/gamepad_viewer.rs, +//! which is used here under the MIT License <3 + +//! Shows a visualization of gamepad buttons, sticks, and triggers + +use bevy::prelude::*; +use leafwing_input_playback::{ + input_capture::{BeginInputCapture, EndInputCapture, InputCapturePlugin}, + input_playback::{BeginInputPlayback, EndInputPlayback, InputPlaybackPlugin, PlaybackStrategy}, + timestamped_input::TimestampedInputs, +}; + +fn main() { + use gamepad_viewer_example::*; + + let mut app = App::new(); + app.add_plugins(( + // This plugin contains all the code from the original example + GamepadViewerExample, + InputCapturePlugin, + InputPlaybackPlugin, + )) + // Toggle between playback and capture using Space + .insert_resource(InputStrategy::Playback) + .add_systems(Update, toggle_capture_vs_playback); + + app.run(); +} + +#[derive(Resource, PartialEq)] +enum InputStrategy { + Capture, + Playback, +} + +fn toggle_capture_vs_playback( + mut commands: Commands, + mut input_strategy: ResMut, + keyboard_input: Res>, + timestamped_input: Option>, +) { + if keyboard_input.just_pressed(KeyCode::Space) { + *input_strategy = match *input_strategy { + InputStrategy::Capture => { + // Disable input capture + commands.trigger(EndInputCapture); + // Enable input playback + if let Some((start, end)) = + // Play back all recorded inputs at the same rate they were input + timestamped_input + .and_then(|timestamped_input| timestamped_input.frame_range()) + { + commands.trigger(BeginInputPlayback { + playback_strategy: PlaybackStrategy::FrameRangeOnce(start, end), + ..default() + }); + info!("Now playing back input."); + } else { + info!("No input to replay."); + } + + InputStrategy::Playback + } + InputStrategy::Playback => { + // Disable input playback, resetting all input data. + commands.trigger(EndInputPlayback); + // Enable input capture + commands.trigger(BeginInputCapture { + filepath: Some("./data/hello_world.ron".to_string()), + ..default() + }); + + info!("Now capturing input."); + InputStrategy::Capture + } + }; + } +} + +mod gamepad_viewer_example { + /// This is the main function from the example adapted from + /// https://github.com/bevyengine/bevy/blob/main/examples/tools/gamepad_viewer.rs + pub struct GamepadViewerExample; + + impl Plugin for GamepadViewerExample { + fn build(&self, app: &mut App) { + app.add_plugins(DefaultPlugins) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems( + Startup, + (setup, setup_sticks, setup_triggers, setup_connected), + ) + .add_systems( + Update, + ( + update_buttons, + update_button_values, + update_axes, + update_connected, + ), + ); + } + } + + use std::f32::consts::PI; + + use bevy::{ + color::palettes, + input::gamepad::{GamepadButton, GamepadButtonChangedEvent, GamepadEvent, GamepadSettings}, + prelude::*, + sprite::{MaterialMesh2dBundle, Mesh2dHandle}, + }; + + const BUTTON_RADIUS: f32 = 25.; + const BUTTON_CLUSTER_RADIUS: f32 = 50.; + const START_SIZE: Vec2 = Vec2::new(30., 15.); + const TRIGGER_SIZE: Vec2 = Vec2::new(70., 20.); + const STICK_BOUNDS_SIZE: f32 = 100.; + + const BUTTONS_X: f32 = 150.; + const BUTTONS_Y: f32 = 80.; + const STICKS_X: f32 = 150.; + const STICKS_Y: f32 = -135.; + + const NORMAL_BUTTON_COLOR: Color = Color::srgb(0.2, 0.2, 0.2); + const ACTIVE_BUTTON_COLOR: Color = Color::Srgba(palettes::css::PURPLE); + const LIVE_COLOR: Color = Color::srgb(0.4, 0.4, 0.4); + const DEAD_COLOR: Color = Color::srgb(0.3, 0.3, 0.3); + const EXTENT_COLOR: Color = Color::srgb(0.3, 0.3, 0.3); + const TEXT_COLOR: Color = Color::WHITE; + + #[derive(Component, Deref)] + struct ReactTo(GamepadButtonType); + #[derive(Component)] + struct MoveWithAxes { + x_axis: GamepadAxisType, + y_axis: GamepadAxisType, + scale: f32, + } + #[derive(Component)] + struct TextWithAxes { + x_axis: GamepadAxisType, + y_axis: GamepadAxisType, + } + #[derive(Component, Deref)] + struct TextWithButtonValue(GamepadButtonType); + + #[derive(Component)] + struct ConnectedGamepadsText; + + #[derive(Resource)] + struct ButtonMaterials { + normal: Handle, + active: Handle, + } + + impl FromWorld for ButtonMaterials { + fn from_world(world: &mut World) -> Self { + let mut materials = world.resource_mut::>(); + Self { + normal: materials.add(ColorMaterial::from(NORMAL_BUTTON_COLOR)), + active: materials.add(ColorMaterial::from(ACTIVE_BUTTON_COLOR)), + } + } + } + #[derive(Resource)] + struct ButtonMeshes { + circle: Mesh2dHandle, + triangle: Mesh2dHandle, + start_pause: Mesh2dHandle, + trigger: Mesh2dHandle, + } + + impl FromWorld for ButtonMeshes { + fn from_world(world: &mut World) -> Self { + let mut meshes = world.resource_mut::>(); + Self { + circle: meshes.add(Circle::new(BUTTON_RADIUS).mesh()).into(), + triangle: meshes + .add(RegularPolygon::new(BUTTON_RADIUS, 3).mesh()) + .into(), + start_pause: meshes + .add(Rectangle::new(START_SIZE.x, START_SIZE.y).mesh()) + .into(), + trigger: meshes + .add(Rectangle::new(TRIGGER_SIZE.x, TRIGGER_SIZE.y).mesh()) + .into(), + } + } + } + #[derive(Resource, Deref)] + struct FontHandle(Handle); + impl FromWorld for FontHandle { + fn from_world(world: &mut World) -> Self { + let asset_server = world.resource::(); + Self(asset_server.load("fonts/FiraSans-Bold.ttf")) + } + } + + fn setup(mut commands: Commands, meshes: Res, materials: Res) { + commands.spawn(Camera2dBundle::default()); + + // Buttons + + commands + .spawn(SpatialBundle { + transform: Transform::from_xyz(BUTTONS_X, BUTTONS_Y, 0.), + ..default() + }) + .with_children(|parent| { + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.circle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(0., BUTTON_CLUSTER_RADIUS, 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::North)); + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.circle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(0., -BUTTON_CLUSTER_RADIUS, 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::South)); + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.circle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(-BUTTON_CLUSTER_RADIUS, 0., 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::West)); + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.circle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(BUTTON_CLUSTER_RADIUS, 0., 0.), + + ..default() + }) + .insert(ReactTo(GamepadButtonType::East)); + }); + + // Start and Pause + + commands + .spawn(MaterialMesh2dBundle { + mesh: meshes.start_pause.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(-30., BUTTONS_Y, 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::Select)); + + commands + .spawn(MaterialMesh2dBundle { + mesh: meshes.start_pause.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(30., BUTTONS_Y, 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::Start)); + + // D-Pad + + commands + .spawn(SpatialBundle { + transform: Transform::from_xyz(-BUTTONS_X, BUTTONS_Y, 0.), + ..default() + }) + .with_children(|parent| { + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.triangle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(0., BUTTON_CLUSTER_RADIUS, 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::DPadUp)); + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.triangle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(0., -BUTTON_CLUSTER_RADIUS, 0.) + .with_rotation(Quat::from_rotation_z(PI)), + ..default() + }) + .insert(ReactTo(GamepadButtonType::DPadDown)); + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.triangle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(-BUTTON_CLUSTER_RADIUS, 0., 0.) + .with_rotation(Quat::from_rotation_z(PI / 2.)), + ..default() + }) + .insert(ReactTo(GamepadButtonType::DPadLeft)); + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.triangle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(BUTTON_CLUSTER_RADIUS, 0., 0.) + .with_rotation(Quat::from_rotation_z(-PI / 2.)), + ..default() + }) + .insert(ReactTo(GamepadButtonType::DPadRight)); + }); + + // Triggers + + commands + .spawn(MaterialMesh2dBundle { + mesh: meshes.trigger.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(-BUTTONS_X, BUTTONS_Y + 115., 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::LeftTrigger)); + + commands + .spawn(MaterialMesh2dBundle { + mesh: meshes.trigger.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(BUTTONS_X, BUTTONS_Y + 115., 0.), + ..default() + }) + .insert(ReactTo(GamepadButtonType::RightTrigger)); + } + + fn setup_sticks( + mut commands: Commands, + meshes: Res, + materials: Res, + gamepad_settings: Res, + font: Res, + ) { + let dead_upper = + STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.deadzone_upperbound(); + let dead_lower = + STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.deadzone_lowerbound(); + let dead_size = dead_lower.abs() + dead_upper.abs(); + let dead_mid = (dead_lower + dead_upper) / 2.0; + + let live_upper = + STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.livezone_upperbound(); + let live_lower = + STICK_BOUNDS_SIZE * gamepad_settings.default_axis_settings.livezone_lowerbound(); + let live_size = live_lower.abs() + live_upper.abs(); + let live_mid = (live_lower + live_upper) / 2.0; + + let mut spawn_stick = |x_pos, y_pos, x_axis, y_axis, button| { + commands + .spawn(SpatialBundle { + transform: Transform::from_xyz(x_pos, y_pos, 0.), + ..default() + }) + .with_children(|parent| { + // full extent + parent.spawn(SpriteBundle { + sprite: Sprite { + custom_size: Some(Vec2::splat(STICK_BOUNDS_SIZE * 2.)), + color: EXTENT_COLOR, + ..default() + }, + ..default() + }); + // live zone + parent.spawn(SpriteBundle { + transform: Transform::from_xyz(live_mid, live_mid, 2.), + sprite: Sprite { + custom_size: Some(Vec2::new(live_size, live_size)), + color: LIVE_COLOR, + ..default() + }, + ..default() + }); + // dead zone + parent.spawn(SpriteBundle { + transform: Transform::from_xyz(dead_mid, dead_mid, 3.), + sprite: Sprite { + custom_size: Some(Vec2::new(dead_size, dead_size)), + color: DEAD_COLOR, + ..default() + }, + ..default() + }); + // text + let style = TextStyle { + font_size: 16., + color: TEXT_COLOR, + font: font.clone(), + }; + parent + .spawn(Text2dBundle { + transform: Transform::from_xyz(0., STICK_BOUNDS_SIZE + 2., 4.), + text: Text::from_sections([ + TextSection { + value: format!("{:.3}", 0.), + style: style.clone(), + }, + TextSection { + value: ", ".to_string(), + style: style.clone(), + }, + TextSection { + value: format!("{:.3}", 0.), + style, + }, + ]) + .with_justify(JustifyText::Center), + ..default() + }) + .insert(TextWithAxes { x_axis, y_axis }); + // cursor + parent + .spawn(MaterialMesh2dBundle { + mesh: meshes.circle.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(0., 0., 5.) + .with_scale(Vec2::splat(0.2).extend(1.)), + ..default() + }) + .insert(MoveWithAxes { + x_axis, + y_axis, + scale: STICK_BOUNDS_SIZE, + }) + .insert(ReactTo(button)); + }); + }; + + spawn_stick( + -STICKS_X, + STICKS_Y, + GamepadAxisType::LeftStickX, + GamepadAxisType::LeftStickY, + GamepadButtonType::LeftThumb, + ); + spawn_stick( + STICKS_X, + STICKS_Y, + GamepadAxisType::RightStickX, + GamepadAxisType::RightStickY, + GamepadButtonType::RightThumb, + ); + } + + fn setup_triggers( + mut commands: Commands, + meshes: Res, + materials: Res, + font: Res, + ) { + let mut spawn_trigger = |x, y, button_type| { + commands + .spawn(MaterialMesh2dBundle { + mesh: meshes.trigger.clone(), + material: materials.normal.clone(), + transform: Transform::from_xyz(x, y, 0.), + ..default() + }) + .insert(ReactTo(button_type)) + .with_children(|parent| { + parent + .spawn(Text2dBundle { + transform: Transform::from_xyz(0., 0., 1.), + text: Text::from_section( + format!("{:.3}", 0.), + TextStyle { + font: font.clone(), + font_size: 16., + color: TEXT_COLOR, + }, + ) + .with_justify(JustifyText::Center), + ..default() + }) + .insert(TextWithButtonValue(button_type)); + }); + }; + + spawn_trigger( + -BUTTONS_X, + BUTTONS_Y + 145., + GamepadButtonType::LeftTrigger2, + ); + spawn_trigger( + BUTTONS_X, + BUTTONS_Y + 145., + GamepadButtonType::RightTrigger2, + ); + } + + fn setup_connected(mut commands: Commands, font: Res) { + let style = TextStyle { + color: TEXT_COLOR, + font_size: 30., + font: font.clone(), + }; + commands + .spawn(TextBundle::from_sections([ + TextSection { + value: "Connected Gamepads\n".to_string(), + style: style.clone(), + }, + TextSection { + value: "None".to_string(), + style, + }, + ])) + .insert(ConnectedGamepadsText); + } + + fn update_buttons( + gamepads: Res, + button_inputs: Res>, + materials: Res, + mut query: Query<(&mut Handle, &ReactTo)>, + ) { + for gamepad in gamepads.iter() { + for (mut handle, react_to) in query.iter_mut() { + if button_inputs.just_pressed(GamepadButton::new(gamepad, **react_to)) { + *handle = materials.active.clone(); + } + if button_inputs.just_released(GamepadButton::new(gamepad, **react_to)) { + *handle = materials.normal.clone(); + } + } + } + } + + fn update_button_values( + mut events: EventReader, + mut query: Query<(&mut Text, &TextWithButtonValue)>, + ) { + for event in events.read() { + if let GamepadEvent::Button(GamepadButtonChangedEvent { + gamepad: _, + button_type, + value, + }) = event + { + for (mut text, text_with_button_value) in query.iter_mut() { + if *button_type == **text_with_button_value { + text.sections[0].value = format!("{:.3}", value); + } + } + } + } + } + + fn update_axes( + mut events: EventReader, + mut query: Query<(&mut Transform, &MoveWithAxes)>, + mut text_query: Query<(&mut Text, &TextWithAxes)>, + ) { + for event in events.read() { + if let GamepadEvent::Axis(axis_changed_event) = event { + let axis_type = axis_changed_event.axis_type; + let value = axis_changed_event.value; + + for (mut transform, move_with) in query.iter_mut() { + if axis_type == move_with.x_axis { + transform.translation.x = value * move_with.scale; + } + if axis_type == move_with.y_axis { + transform.translation.y = value * move_with.scale; + } + } + for (mut text, text_with_axes) in text_query.iter_mut() { + if axis_type == text_with_axes.x_axis { + text.sections[0].value = format!("{:.3}", value); + } + if axis_type == text_with_axes.y_axis { + text.sections[2].value = format!("{:.3}", value); + } + } + } + } + } + + fn update_connected( + gamepads: Res, + mut query: Query<&mut Text, With>, + ) { + if !gamepads.is_changed() { + return; + } + + let mut text = query.single_mut(); + + let formatted = gamepads + .iter() + .map(|g| format!("{:?}", g)) + .collect::>() + .join("\n"); + + text.sections[1].value = if !formatted.is_empty() { + formatted + } else { + "None".to_string() + } + } +} diff --git a/examples/input_capture.rs b/examples/input_capture.rs index 25161c5..4f8739a 100644 --- a/examples/input_capture.rs +++ b/examples/input_capture.rs @@ -1,13 +1,15 @@ use bevy::prelude::*; use leafwing_input_playback::{ - input_capture::InputCapturePlugin, timestamped_input::TimestampedInputs, + input_capture::{BeginInputCapture, InputCapturePlugin}, + timestamped_input::TimestampedInputs, }; fn main() -> AppExit { - App::new() - .add_plugins((DefaultPlugins, InputCapturePlugin)) - .add_systems(Update, debug_input_capture) - .run() + let mut app = App::new(); + app.add_plugins((DefaultPlugins, InputCapturePlugin)); + app.add_systems(Update, debug_input_capture); + app.world_mut().trigger(BeginInputCapture::default()); + app.run() } // TimestampedInput is an iterator, so we require mutable access to track which events we've seen diff --git a/examples/input_playback.rs b/examples/input_playback.rs index f75fac3..5bceb77 100644 --- a/examples/input_playback.rs +++ b/examples/input_playback.rs @@ -1,139 +1,130 @@ -use bevy::{color::palettes, prelude::*, window::PrimaryWindow}; - -use leafwing_input_playback::{ - input_capture::{InputCapturePlugin, InputModesCaptured}, - input_playback::{InputPlaybackPlugin, PlaybackStrategy}, - timestamped_input::TimestampedInputs, -}; - -fn main() -> AppExit { - App::new() - .add_plugins((DefaultPlugins, InputCapturePlugin, InputPlaybackPlugin)) - // Disable all input capture and playback to start - .insert_resource(InputModesCaptured::DISABLE_ALL) - .insert_resource(PlaybackStrategy::Paused) - // Creates a little game that spawns decaying boxes where the player clicks - .insert_resource(ClearColor(Color::srgb(0.9, 0.9, 0.9))) - .add_systems(Startup, setup) - .add_systems( - Update, - (spawn_boxes, decay_boxes, toggle_capture_vs_playback), - ) - // Toggle between playback and capture by pressing Space - .insert_resource(InputStrategy::Playback) - .run() -} - -#[derive(Resource, PartialEq)] -enum InputStrategy { - Capture, - Playback, -} - -fn setup(mut commands: Commands) { - commands.spawn(Camera2dBundle::default()); -} - -pub fn cursor_pos_as_world_pos( - current_window: &Window, - camera_query: &Query<(&Transform, &Camera)>, -) -> Option { - current_window.cursor_position().map(|cursor_pos| { - let (cam_t, cam) = camera_query.single(); - let window_size = Vec2::new(current_window.width(), current_window.height()); - - // Convert screen position [0..resolution] to ndc [-1..1] - let ndc_to_world = cam_t.compute_matrix() * cam.clip_from_view().inverse(); - let ndc = (Vec2::new(cursor_pos.x, cursor_pos.y) / window_size) * 2.0 - Vec2::ONE; - let world_pos = ndc_to_world.project_point3(ndc.extend(-1.0)); - world_pos.truncate() - }) -} - -#[derive(Component)] -struct Box; - -fn spawn_boxes( - mut commands: Commands, - windows: Query<&Window, With>, - mouse_input: Res>, - camera_query: Query<(&Transform, &Camera)>, -) { - const BOX_SCALE: f32 = 50.0; - - if mouse_input.pressed(MouseButton::Left) { - let primary_window = windows.single(); - // Don't break if we leave the window - if let Some(cursor_pos) = cursor_pos_as_world_pos(primary_window, &camera_query) { - commands - .spawn(SpriteBundle { - sprite: Sprite { - color: Color::Srgba(palettes::css::DARK_GREEN), - ..default() - }, - transform: Transform { - translation: cursor_pos.extend(0.0), - scale: Vec3::splat(BOX_SCALE), - ..default() - }, - ..default() - }) - .insert(Box); - } - } -} - -fn decay_boxes(mut query: Query<(Entity, &mut Transform), With>, mut commands: Commands) { - const MIN_SCALE: f32 = 1.; - const SHRINK_FACTOR: f32 = 0.95; - - for (entity, mut transform) in query.iter_mut() { - if transform.scale.x < MIN_SCALE { - commands.entity(entity).despawn(); - } else { - transform.scale *= SHRINK_FACTOR; - } - } -} - -fn toggle_capture_vs_playback( - mut input_modes: ResMut, - mut playback_strategy: ResMut, - keyboard_input: Res>, - mut timestamped_input: ResMut, - mut input_strategy: ResMut, -) { - if keyboard_input.just_pressed(KeyCode::Space) { - *input_strategy = match *input_strategy { - InputStrategy::Capture => { - // Disable input capture - *input_modes = InputModesCaptured::DISABLE_ALL; - // Enable input playback - *playback_strategy = if let Some((start, end)) = - // Play back all recorded inputs at the same rate they were input - timestamped_input.frame_range() - { - PlaybackStrategy::FrameRangeOnce(start, end) - } else { - // Do not play back events if none were recorded - PlaybackStrategy::Paused - }; - - info!("Now playing back input."); - InputStrategy::Playback - } - InputStrategy::Playback => { - // Enable input capture - *input_modes = InputModesCaptured::ENABLE_ALL; - // Disable input playback - *playback_strategy = PlaybackStrategy::Paused; - - // Reset all input data, starting a new recording - *timestamped_input = TimestampedInputs::default(); - - info!("Now capturing input."); - InputStrategy::Capture - } - }; - } -} +use bevy::{color::palettes, prelude::*, window::PrimaryWindow}; + +use leafwing_input_playback::{ + input_capture::{BeginInputCapture, EndInputCapture, InputCapturePlugin}, + input_playback::{BeginInputPlayback, EndInputPlayback, InputPlaybackPlugin, PlaybackStrategy}, + timestamped_input::TimestampedInputs, +}; + +fn main() -> AppExit { + let mut app = App::new(); + app.add_plugins((DefaultPlugins, InputCapturePlugin, InputPlaybackPlugin)) + // Creates a little game that spawns decaying boxes where the player clicks + .insert_resource(ClearColor(Color::srgb(0.9, 0.9, 0.9))) + // Toggle between playback and capture by pressing Space + .insert_resource(InputStrategy::Playback) + .add_systems(Startup, setup) + .add_systems( + Update, + (spawn_boxes, decay_boxes, toggle_capture_vs_playback), + ); + app.run() +} + +#[derive(Resource, PartialEq)] +enum InputStrategy { + Capture, + Playback, +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2dBundle::default()); +} + +pub fn cursor_pos_as_world_pos( + current_window: &Window, + camera_query: &Query<(&GlobalTransform, &Camera)>, +) -> Option { + let (camera_transform, camera) = camera_query.single(); + current_window + .cursor_position() + .and_then(|cursor| camera.viewport_to_world(camera_transform, cursor)) + .map(|ray| ray.origin.truncate()) +} + +#[derive(Component)] +struct Box; + +fn spawn_boxes( + mut commands: Commands, + windows: Query<&Window, With>, + mouse_input: Res>, + camera_query: Query<(&GlobalTransform, &Camera)>, +) { + const BOX_SCALE: f32 = 50.0; + + if mouse_input.pressed(MouseButton::Left) { + let primary_window = windows.single(); + // Don't break if we leave the window + if let Some(cursor_pos) = cursor_pos_as_world_pos(primary_window, &camera_query) { + commands + .spawn(SpriteBundle { + sprite: Sprite { + color: Color::Srgba(palettes::css::DARK_GREEN), + ..default() + }, + transform: Transform { + translation: cursor_pos.extend(0.0), + scale: Vec3::splat(BOX_SCALE), + ..default() + }, + ..default() + }) + .insert(Box); + } + } +} + +fn decay_boxes(mut query: Query<(Entity, &mut Transform), With>, mut commands: Commands) { + const MIN_SCALE: f32 = 1.; + const SHRINK_FACTOR: f32 = 0.95; + + for (entity, mut transform) in query.iter_mut() { + if transform.scale.x < MIN_SCALE { + commands.entity(entity).despawn(); + } else { + transform.scale *= SHRINK_FACTOR; + } + } +} + +fn toggle_capture_vs_playback( + mut commands: Commands, + mut input_strategy: ResMut, + keyboard_input: Res>, + timestamped_input: Option>, +) { + if keyboard_input.just_pressed(KeyCode::Space) { + *input_strategy = match *input_strategy { + InputStrategy::Capture => { + // Disable input capture + commands.trigger(EndInputCapture); + // Enable input playback + if let Some((start, end)) = + // Play back all recorded inputs at the same rate they were input + timestamped_input + .and_then(|timestamped_input| timestamped_input.frame_range()) + { + commands.trigger(BeginInputPlayback { + playback_strategy: PlaybackStrategy::FrameRangeOnce(start, end), + ..default() + }); + info!("Now playing back input."); + } else { + info!("No input to replay."); + } + + InputStrategy::Playback + } + InputStrategy::Playback => { + // Disable input playback, resetting all input data. + commands.trigger(EndInputPlayback); + // Enable input capture + commands.trigger(BeginInputCapture::default()); + + info!("Now capturing input."); + InputStrategy::Capture + } + }; + } +} diff --git a/examples/playback_serialized_input.rs b/examples/playback_serialized_input.rs index 33a98fc..ef1e40b 100644 --- a/examples/playback_serialized_input.rs +++ b/examples/playback_serialized_input.rs @@ -1,19 +1,24 @@ -/// Demonstrates reading saved inputs from disk, and playing them back. -use bevy::input::keyboard::KeyboardInput; -use bevy::prelude::*; -use leafwing_input_playback::input_playback::InputPlaybackPlugin; -use leafwing_input_playback::serde::PlaybackFilePath; - -fn main() { - App::new() - .add_plugins((DefaultPlugins, InputPlaybackPlugin)) - .insert_resource(PlaybackFilePath::new("./data/hello_world.ron")) - .add_systems(Update, debug_keyboard_inputs) - .run(); -} - -fn debug_keyboard_inputs(mut keyboard_events: EventReader) { - for keyboard_event in keyboard_events.read() { - dbg!(keyboard_event); - } -} +/// Demonstrates reading saved inputs from disk, and playing them back. +use bevy::input::keyboard::KeyboardInput; +use bevy::prelude::*; +use leafwing_input_playback::input_playback::{ + BeginInputPlayback, InputPlaybackPlugin, InputPlaybackSource, +}; + +fn main() { + let mut app = App::new(); + app.add_plugins((DefaultPlugins, InputPlaybackPlugin)); + app.add_systems(Update, debug_keyboard_inputs); + + app.world_mut().trigger(BeginInputPlayback { + source: Some(InputPlaybackSource::from_file("./data/hello_world.ron")), + ..Default::default() + }); + app.run(); +} + +fn debug_keyboard_inputs(mut keyboard_events: EventReader) { + for keyboard_event in keyboard_events.read() { + dbg!(keyboard_event); + } +} diff --git a/examples/serialize_captured_input.rs b/examples/serialize_captured_input.rs index c1d6e89..386a50d 100644 --- a/examples/serialize_captured_input.rs +++ b/examples/serialize_captured_input.rs @@ -2,17 +2,21 @@ /// /// Just enter inputs, and watch them be serialized to disk. use bevy::prelude::*; -use leafwing_input_playback::input_capture::{InputCapturePlugin, InputModesCaptured}; -use leafwing_input_playback::serde::PlaybackFilePath; +use leafwing_input_playback::input_capture::{ + trigger_input_capture_on_exit, BeginInputCapture, InputCapturePlugin, InputModesCaptured, +}; fn main() { - App::new() - .add_plugins((DefaultPlugins, InputCapturePlugin)) - .insert_resource(PlaybackFilePath::new("./data/test_playback.ron")) - // In this example, we're only capturing keyboard inputs - .insert_resource(InputModesCaptured { + let mut app = App::new(); + app.add_plugins((DefaultPlugins, InputCapturePlugin)) + .add_systems(Last, trigger_input_capture_on_exit); + app.world_mut().trigger(BeginInputCapture { + input_modes_captured: InputModesCaptured { keyboard: true, ..InputModesCaptured::DISABLE_ALL - }) - .run(); + }, + filepath: Some("./data/test_playback.ron".to_string()), + ..Default::default() + }); + app.run(); } diff --git a/examples/useless_machine.rs b/examples/useless_machine.rs index e5cd4c3..d2c31bb 100644 --- a/examples/useless_machine.rs +++ b/examples/useless_machine.rs @@ -1,14 +1,18 @@ -//! [`AppExit`] events are played back and captured too! -//! -//! This example loads the file, which only contains an `AppExit`, -//! and then immediately quits itself as soon as it is encountered. -use bevy::prelude::*; -use leafwing_input_playback::input_playback::InputPlaybackPlugin; -use leafwing_input_playback::serde::PlaybackFilePath; - -fn main() { - App::new() - .add_plugins((DefaultPlugins, InputPlaybackPlugin)) - .insert_resource(PlaybackFilePath::new("./data/app_exit.ron")) - .run(); -} +//! [`AppExit`] events are played back and captured too! +//! +//! This example loads the file, which only contains an `AppExit`, +//! and then immediately quits itself as soon as it is encountered. +use bevy::prelude::*; +use leafwing_input_playback::input_playback::{ + BeginInputPlayback, InputPlaybackPlugin, InputPlaybackSource, +}; + +fn main() { + let mut app = App::new(); + app.add_plugins((DefaultPlugins, InputPlaybackPlugin)); + app.world_mut().trigger(BeginInputPlayback { + source: Some(InputPlaybackSource::from_file("./data/app_exit.ron")), + ..Default::default() + }); + app.run(); +} diff --git a/src/input_capture.rs b/src/input_capture.rs index 6e0443c..7b32182 100644 --- a/src/input_capture.rs +++ b/src/input_capture.rs @@ -33,7 +33,7 @@ impl Plugin for InputCapturePlugin { Last, ( // Capture any mocked input as well - capture_input, + capture_input.run_if(resource_exists::), handle_final_capture_frame.run_if(resource_exists::), ) .chain() @@ -42,7 +42,7 @@ impl Plugin for InputCapturePlugin { } } -/// An Event that users can send to initiate input capture. +/// An Observer that users can trigger to initiate input capture. /// /// Data is serialized to the provided `filepath` when either an [`EndInputCapture`] or an [`AppExit`] event is detected. #[derive(Debug, Default, Event)] @@ -60,7 +60,7 @@ pub struct BeginInputCapture { } impl BeginInputCapture { - /// Initiates input capture when a [`BeginInputCapture`] is detected. + /// An `ObserverSystem` for `BeginInputCapture` that attaches all capture-related resources. pub fn observer(trigger: Trigger, mut commands: Commands, frame_count: Res) { let event = trigger.event(); commands.init_resource::(); @@ -79,12 +79,12 @@ impl BeginInputCapture { } } -/// An Observer Trigger that users can send to end input capture and serialize data to disk. +/// An Observer that users can trigger to end input capture and serialize data to disk. #[derive(Debug, Event)] pub struct EndInputCapture; impl EndInputCapture { - /// An `ObserverSystem` for `EndInputCapture` that removes all capture-related resources and serializes timestamps if `PlaybackFilePath` exists + /// An `ObserverSystem` for `EndInputCapture` that removes all capture-related resources and serializes timestamps if `PlaybackFilePath` exists. pub fn observer( _trigger: Trigger, mut commands: Commands, diff --git a/src/input_playback.rs b/src/input_playback.rs index 2bcc164..2068e71 100644 --- a/src/input_playback.rs +++ b/src/input_playback.rs @@ -1,442 +1,469 @@ -//! Reads user input from a single [`TimestampedInputs`](crate::timestamped_input::TimestampedInputs) resource. -//! -//! These are played back by emulating assorted Bevy input events. - -use bevy::app::{App, AppExit, First, Plugin}; -use bevy::core::FrameCount; -use bevy::ecs::{prelude::*, system::SystemParam}; -use bevy::input::{ - gamepad::GamepadEvent, - keyboard::KeyboardInput, - mouse::{MouseButtonInput, MouseWheel}, -}; -use bevy::log::warn; -use bevy::time::Time; -use bevy::utils::Duration; -use bevy::window::{CursorMoved, Window}; -use ron::de::from_reader; -use std::fs::File; - -use crate::serde::PlaybackFilePath; -use crate::timestamped_input::{TimestampedInputEvent, TimestampedInputs}; - -/// Reads from the [`TimestampedInputs`] event stream to determine which events to play back. -/// -/// Events are played back during the [`First`] schedule to accurately mimic the behavior of native `winit`-based inputs. -/// Which events are played back are controlled via the [`PlaybackStrategy`] resource. -/// -/// Input is deserialized on app startup from the path stored in the [`PlaybackFilePath`] resource, if any. -pub struct InputPlaybackPlugin; - -impl Plugin for InputPlaybackPlugin { - fn build(&self, app: &mut App) { - app.observe(BeginInputPlayback::observer) - .observe(EndInputPlayback::observer) - .add_systems( - First, - playback_timestamped_input - .run_if( - resource_exists:: - .and_then(resource_exists::), - ) - .after(bevy::ecs::event::EventUpdates), - ); - } -} - -/// An Event that users can send to initiate input capture. -/// -/// Data is serialized to the provided `filepath` when either an [`EndCaptureEvent`] or an [`AppExit`] event is detected. -#[derive(Debug, Default, Event)] -pub struct BeginInputPlayback { - /// The source from which to read input data. Do not provide a `source` if the expected `TimestampedInputs` should already be present in `World`. - pub source: Option, - /// Controls the approach used for playing back recorded inputs. - /// - /// See [`PlaybackStrategy`] for more information. - pub playback_strategy: PlaybackStrategy, - /// A entity corresponding to the [`bevy::window::Window`] which will receive input events. - /// If unspecified, input events will target the serialized window entity, which may be fragile. - pub playback_window: Option, -} - -impl BeginInputPlayback { - /// Initiates input playback and deserializes timestamped inputs from the provided playback source. - pub fn observer(trigger: Trigger, mut commands: Commands) { - let event = trigger.event(); - commands.init_resource::(); - commands.insert_resource(event.playback_strategy); - - if let Some(source) = event.source.as_ref() { - let timestamped_inputs = match source { - InputPlaybackSource::TimestampedInputs(inputs) => inputs.clone(), - InputPlaybackSource::File(playback_path) => { - commands.insert_resource(playback_path.clone()); - deserialize_timestamped_inputs(playback_path) - .unwrap() - .unwrap() - } - }; - commands.insert_resource(timestamped_inputs); - } - - if let Some(playback_window) = event.playback_window { - commands.insert_resource(PlaybackWindow(playback_window)); - } - } -} - -/// The source of input data for playback. -/// -/// Typically users should expect to provide a `FilePath`, but `TimestampedInputs` can still be provided manually. -#[derive(Debug)] -pub enum InputPlaybackSource { - /// Reads from a file and deserializes the content into a `TimestampedInputs`. - File(PlaybackFilePath), - /// Uses the provided `TimestampedInputs` parameter as the source of input data. - TimestampedInputs(TimestampedInputs), -} - -impl InputPlaybackSource { - /// Reads source data from a file using the provided filepath. - pub fn from_file(filepath: impl AsRef) -> Self { - InputPlaybackSource::File(PlaybackFilePath::new(filepath.as_ref())) - } - - /// Defines source data using raw data. - pub fn from_inputs(inputs: TimestampedInputs) -> Self { - InputPlaybackSource::TimestampedInputs(inputs) - } -} - -impl Default for InputPlaybackSource { - fn default() -> Self { - Self::TimestampedInputs(TimestampedInputs::default()) - } -} - -/// An Event that users can send to end input playback prematurely. -#[derive(Debug, Event)] -pub struct EndInputPlayback; - -impl EndInputPlayback { - /// Serializes captured input to the path given in the [`PlaybackFilePath`] resource when an [`EndInputCapture`] is detected. - /// - /// Use the [`serialized_timestamped_inputs`] function directly if you want to implement custom checkpointing strategies. - fn observer(_trigger: Trigger, mut commands: Commands) { - commands.remove_resource::(); - commands.remove_resource::(); - commands.remove_resource::(); - commands.remove_resource::(); - commands.remove_resource::(); - } -} - -/// The `Window` entity for which inputs will be captured. -/// -/// If this Resource is attached, input events will be forwarded to this window entity rather than the serialized window entity. -#[derive(Debug, Resource)] -pub struct PlaybackWindow(pub Entity); - -/// Controls the approach used for playing back recorded inputs -/// -/// [`PlaybackStrategy::Time`] is the default strategy. -#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PlaybackStrategy { - /// Plays events up to (but not past) the current [`Time`]. - /// - /// This strategy is more reliable, as it will ensure that systems which rely on elapsed time function correctly. - #[default] - Time, - /// Plays events up to (but not past) the current [`FrameCount`]. - /// - /// This strategy is faster, as you can turn off any frame rate limiting mechanism. - FrameCount, - /// Plays events between the first and second [`Duration`] once, measured in time since app startup. - /// - /// The events are played back at the same rate they were captured. - /// This range includes events sent at the start of the range, but not the end. - TimeRangeOnce(Duration, Duration), - /// Plays events between the first and second [`Duration`] indefinitely, measured in time since app startup. - /// - /// The events are played back at the same rate they were captured. - /// This range includes events sent at the start of the range, but not the end. - /// There will always be one frame between the end of the previous loop and the start of the next. - TimeRangeLoop(Duration, Duration), - /// Plays events between the first and second [`FrameCount`] once. - /// - /// The events are played back at the same rate they were captured. - /// This range includes events sent at the start of the range, but not the end. - FrameRangeOnce(FrameCount, FrameCount), - /// Plays events between the first and second [`FrameCount`] indefinitely. - /// - /// The events are played back at the same rate they were captured. - /// This range includes events sent at the start of the range, but not the end. - /// There will always be one frame between the end of the previous loop and the start of the next. - FrameRangeLoop(FrameCount, FrameCount), - /// Does not playback any events. - /// - /// This is useful for interactive use cases, to temporarily disable sending events. - Paused, -} - -/// The [`EventWriter`] types that correspond to the input event types stored in [`InputEvent`](crate::timestamped_input::InputEvent) -#[derive(SystemParam)] -#[allow(missing_docs)] -pub struct InputWriters<'w, 's> { - pub keyboard_input: EventWriter<'w, KeyboardInput>, - pub mouse_button_input: EventWriter<'w, MouseButtonInput>, - pub mouse_wheel: EventWriter<'w, MouseWheel>, - pub cursor_moved: EventWriter<'w, CursorMoved>, - pub windows: Query<'w, 's, &'static mut Window>, - pub gamepad: EventWriter<'w, GamepadEvent>, - pub app_exit: EventWriter<'w, AppExit>, -} - -// `TimestampedInputs` is an iterator, so we need mutable access to be able to track which events we've seen -/// A system that reads from the [`TimestampedInputs`] resources and plays back the contained events. -/// -/// The strategy used is based on [`PlaybackStrategy`]. -pub fn playback_timestamped_input( - mut timestamped_input: ResMut, - mut playback_strategy: ResMut, - time: Res