bevy/examples/ecs/fallible_params.rs
Alice Cecile 6caa1782d6 Define system param validation on a per-system parameter basis (#18504)
# Objective

When introduced, `Single` was intended to simply be silently skipped,
allowing for graceful and efficient handling of systems during invalid
game states (such as when the player is dead).

However, this also caused missing resources to *also* be silently
skipped, leading to confusing and very hard to debug failures. In
0.15.1, this behavior was reverted to a panic, making missing resources
easier to debug, but largely making `Single` (and `Populated`)
worthless, as they would panic during expected game states.

Ultimately, the consensus is that this behavior should differ on a
per-system-param basis. However, there was no sensible way to *do* that
before this PR.

## Solution

Swap `SystemParam::validate_param` from a `bool` to:

```rust
/// The outcome of system / system param validation,
/// used by system executors to determine what to do with a system.
pub enum ValidationOutcome {
    /// All system parameters were validated successfully and the system can be run.
    Valid,
    /// At least one system parameter failed validation, and an error must be handled.
    /// By default, this will result in1 a panic. See [crate::error] for more information.
    ///
    /// This is the default behavior, and is suitable for system params that should *always* be valid,
    /// either because sensible fallback behavior exists (like [`Query`] or because
    /// failures in validation should be considered a bug in the user's logic that must be immediately addressed (like [`Res`]).
    Invalid,
    /// At least one system parameter failed validation, but the system should be skipped due to [`ValidationBehavior::Skip`].
    /// This is suitable for system params that are intended to only operate in certain application states, such as [`Single`].
    Skipped,
}
```
Then, inside of each `SystemParam` implementation, return either Valid,
Invalid or Skipped.

Currently, only `Single`, `Option<Single>` and `Populated` use the
`Skipped` behavior. Other params (like resources) retain their current
failing

## Testing

Messed around with the fallible_params example. Added a pair of tests:
one for panicking when resources are missing, and another for properly
skipping `Single` and `Populated` system params.

## To do

- [x] get https://github.com/bevyengine/bevy/pull/18454 merged
- [x] fix the todo!() in the macro-powered tuple implementation (please
help 🥺)
- [x] test
- [x] write a migration guide
- [x] update the example comments

## Migration Guide

Various system and system parameter validation methods
(`SystemParam::validate_param`, `System::validate_param` and
`System::validate_param_unsafe`) now return and accept a
`ValidationOutcome` enum, rather than a `bool`. The previous `true`
values map to `ValidationOutcome::Valid`, while `false` maps to
`ValidationOutcome::Invalid`.

However, if you wrote a custom schedule executor, you should now respect
the new `ValidationOutcome::Skipped` parameter, skipping any systems
whose validation was skipped. By contrast, `ValidationOutcome::Invalid`
systems should also be skipped, but you should call the
`default_error_handler` on them first, which by default will result in a
panic.

If you are implementing a custom `SystemParam`, you should consider
whether failing system param validation is an error or an expected
state, and choose between `Invalid` and `Skipped` accordingly. In Bevy
itself, `Single` and `Populated` now once again skip the system when
their conditions are not met. This is the 0.15.0 behavior, but stands in
contrast to the 0.15.1 behavior, where they would panic.

---------

Co-authored-by: MiniaczQ <xnetroidpl@gmail.com>
Co-authored-by: Dmytro Banin <banind@cs.washington.edu>
Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
2025-03-25 22:59:04 +01:00

169 lines
6.8 KiB
Rust

//! This example demonstrates how fallible parameters can prevent their systems
//! from running if their acquiry conditions aren't met.
//!
//! Fallible system parameters include:
//! - [`Res<R>`], [`ResMut<R>`] - Resource has to exist, and the [`GLOBAL_ERROR_HANDLER`] will be called if it doesn't.
//! - [`Single<D, F>`] - There must be exactly one matching entity, but the system will be silently skipped otherwise.
//! - [`Option<Single<D, F>>`] - There must be zero or one matching entity. The system will be silently skipped if there are more.
//! - [`Populated<D, F>`] - There must be at least one matching entity, but the system will be silently skipped otherwise.
//!
//! Other system parameters, such as [`Query`], will never fail validation: returning a query with no matching entities is valid.
//!
//! The result of failed system parameter validation is determined by the [`ValidationOutcome`] returned
//! by [`SystemParam::validate_param`] for each system parameter.
//! Each system will pass, fail, or skip based on the joint outcome of all its parameters,
//! according to the rules defined in [`ValidationOutcome::combine`].
//!
//! To learn more about setting the fallback behavior for [`ValidationOutcome`] failures,
//! please see the `error_handling.rs` example.
//!
//! [`ValidationOutcome`]: bevy::ecs::system::ValidationOutcome
//! [`ValidationOutcome::combine`]: bevy::ecs::system::ValidationOutcome::combine
//! [`SystemParam::validate_param`]: bevy::ecs::system::SystemParam::validate_param
use bevy::ecs::error::{warn, GLOBAL_ERROR_HANDLER};
use bevy::prelude::*;
use rand::Rng;
fn main() {
// By default, if a parameter fail to be fetched,
// the `GLOBAL_ERROR_HANDLER` will be used to handle the error,
// which by default is set to panic.
GLOBAL_ERROR_HANDLER
.set(warn)
.expect("The error handler can only be set once, globally.");
println!();
println!("Press 'A' to add enemy ships and 'R' to remove them.");
println!("Player ship will wait for enemy ships and track one if it exists,");
println!("but will stop tracking if there are more than one.");
println!();
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (user_input, move_targets, track_targets).chain())
// This system will always fail validation, because we never create an entity with both `Player` and `Enemy` components.
.add_systems(Update, do_nothing_fail_validation)
.run();
}
/// Enemy component stores data for movement in a circle.
#[derive(Component, Default)]
struct Enemy {
origin: Vec2,
radius: f32,
rotation: f32,
rotation_speed: f32,
}
/// Player component stores data for going after enemies.
#[derive(Component, Default)]
struct Player {
speed: f32,
rotation_speed: f32,
min_follow_radius: f32,
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// Spawn 2D camera.
commands.spawn(Camera2d);
// Spawn player.
let texture = asset_server.load("textures/simplespace/ship_C.png");
commands.spawn((
Player {
speed: 100.0,
rotation_speed: 2.0,
min_follow_radius: 50.0,
},
Sprite {
image: texture,
color: bevy::color::palettes::tailwind::BLUE_800.into(),
..Default::default()
},
Transform::from_translation(Vec3::ZERO),
));
}
/// System that reads user input.
/// If user presses 'A' we spawn a new random enemy.
/// If user presses 'R' we remove a random enemy (if any exist).
fn user_input(
mut commands: Commands,
enemies: Query<Entity, With<Enemy>>,
keyboard_input: Res<ButtonInput<KeyCode>>,
asset_server: Res<AssetServer>,
) {
let mut rng = rand::thread_rng();
if keyboard_input.just_pressed(KeyCode::KeyA) {
let texture = asset_server.load("textures/simplespace/enemy_A.png");
commands.spawn((
Enemy {
origin: Vec2::new(rng.gen_range(-200.0..200.0), rng.gen_range(-200.0..200.0)),
radius: rng.gen_range(50.0..150.0),
rotation: rng.gen_range(0.0..std::f32::consts::TAU),
rotation_speed: rng.gen_range(0.5..1.5),
},
Sprite {
image: texture,
color: bevy::color::palettes::tailwind::RED_800.into(),
..default()
},
Transform::from_translation(Vec3::ZERO),
));
}
if keyboard_input.just_pressed(KeyCode::KeyR) {
if let Some(entity) = enemies.iter().next() {
commands.entity(entity).despawn();
}
}
}
// System that moves the enemies in a circle.
// Only runs if there are enemies, due to the `Populated` parameter.
fn move_targets(mut enemies: Populated<(&mut Transform, &mut Enemy)>, time: Res<Time>) {
for (mut transform, mut target) in &mut *enemies {
target.rotation += target.rotation_speed * time.delta_secs();
transform.rotation = Quat::from_rotation_z(target.rotation);
let offset = transform.right() * target.radius;
transform.translation = target.origin.extend(0.0) + offset;
}
}
/// System that moves the player, causing them to track a single enemy.
/// The player will search for enemies if there are none.
/// If there is one, player will track it.
/// If there are too many enemies, the player will cease all action (the system will not run).
fn track_targets(
// `Single` ensures the system runs ONLY when exactly one matching entity exists.
mut player: Single<(&mut Transform, &Player)>,
// `Option<Single>` ensures that the system runs ONLY when zero or one matching entity exists.
enemy: Option<Single<&Transform, (With<Enemy>, Without<Player>)>>,
time: Res<Time>,
) {
let (player_transform, player) = &mut *player;
if let Some(enemy_transform) = enemy {
// Enemy found, rotate and move towards it.
let delta = enemy_transform.translation - player_transform.translation;
let distance = delta.length();
let front = delta / distance;
let up = Vec3::Z;
let side = front.cross(up);
player_transform.rotation = Quat::from_mat3(&Mat3::from_cols(side, front, up));
let max_step = distance - player.min_follow_radius;
if 0.0 < max_step {
let velocity = (player.speed * time.delta_secs()).min(max_step);
player_transform.translation += front * velocity;
}
} else {
// 0 or multiple enemies found, keep searching.
player_transform.rotate_axis(Dir3::Z, player.rotation_speed * time.delta_secs());
}
}
/// This system always fails param validation, because we never
/// create an entity with both [`Player`] and [`Enemy`] components.
fn do_nothing_fail_validation(_: Single<(), (With<Player>, With<Enemy>)>) {}