Merge branch 'main' into Remove-entity-reserving/pending/flushing-system
This commit is contained in:
commit
866476e851
@ -851,6 +851,8 @@ impl Image {
|
||||
|
||||
/// Resizes the image to the new size, by removing information or appending 0 to the `data`.
|
||||
/// Does not properly scale the contents of the image.
|
||||
///
|
||||
/// If you need to keep pixel data intact, use [`Image::resize_in_place`].
|
||||
pub fn resize(&mut self, size: Extent3d) {
|
||||
self.texture_descriptor.size = size;
|
||||
if let Some(ref mut data) = self.data {
|
||||
@ -878,6 +880,52 @@ impl Image {
|
||||
self.texture_descriptor.size = new_size;
|
||||
}
|
||||
|
||||
/// Resizes the image to the new size, keeping the pixel data intact, anchored at the top-left.
|
||||
/// When growing, the new space is filled with 0. When shrinking, the image is clipped.
|
||||
///
|
||||
/// For faster resizing when keeping pixel data intact is not important, use [`Image::resize`].
|
||||
pub fn resize_in_place(&mut self, new_size: Extent3d) -> Result<(), ResizeError> {
|
||||
let old_size = self.texture_descriptor.size;
|
||||
let pixel_size = self.texture_descriptor.format.pixel_size();
|
||||
let byte_len = self.texture_descriptor.format.pixel_size() * new_size.volume();
|
||||
|
||||
let Some(ref mut data) = self.data else {
|
||||
return Err(ResizeError::ImageWithoutData);
|
||||
};
|
||||
|
||||
let mut new: Vec<u8> = vec![0; byte_len];
|
||||
|
||||
let copy_width = old_size.width.min(new_size.width) as usize;
|
||||
let copy_height = old_size.height.min(new_size.height) as usize;
|
||||
let copy_depth = old_size
|
||||
.depth_or_array_layers
|
||||
.min(new_size.depth_or_array_layers) as usize;
|
||||
|
||||
let old_row_stride = old_size.width as usize * pixel_size;
|
||||
let old_layer_stride = old_size.height as usize * old_row_stride;
|
||||
|
||||
let new_row_stride = new_size.width as usize * pixel_size;
|
||||
let new_layer_stride = new_size.height as usize * new_row_stride;
|
||||
|
||||
for z in 0..copy_depth {
|
||||
for y in 0..copy_height {
|
||||
let old_offset = z * old_layer_stride + y * old_row_stride;
|
||||
let new_offset = z * new_layer_stride + y * new_row_stride;
|
||||
|
||||
let old_range = (old_offset)..(old_offset + copy_width * pixel_size);
|
||||
let new_range = (new_offset)..(new_offset + copy_width * pixel_size);
|
||||
|
||||
new[new_range].copy_from_slice(&data[old_range]);
|
||||
}
|
||||
}
|
||||
|
||||
self.data = Some(new);
|
||||
|
||||
self.texture_descriptor.size = new_size;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Takes a 2D image containing vertically stacked images of the same size, and reinterprets
|
||||
/// it as a 2D array texture, where each of the stacked images becomes one layer of the
|
||||
/// array. This is primarily for use with the `texture2DArray` shader uniform type.
|
||||
@ -1540,6 +1588,14 @@ pub enum TextureError {
|
||||
IncompleteCubemap,
|
||||
}
|
||||
|
||||
/// An error that occurs when an image cannot be resized.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ResizeError {
|
||||
/// Failed to resize an Image because it has no data.
|
||||
#[error("resize method requires cpu-side image data but none was present")]
|
||||
ImageWithoutData,
|
||||
}
|
||||
|
||||
/// The type of a raw image buffer.
|
||||
#[derive(Debug)]
|
||||
pub enum ImageType<'a> {
|
||||
@ -1730,4 +1786,173 @@ mod test {
|
||||
image.set_color_at_3d(4, 9, 2, Color::WHITE).unwrap();
|
||||
assert!(matches!(image.get_color_at_3d(4, 9, 2), Ok(Color::WHITE)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_in_place_2d_grow_and_shrink() {
|
||||
use bevy_color::ColorToPacked;
|
||||
|
||||
const INITIAL_FILL: LinearRgba = LinearRgba::BLACK;
|
||||
const GROW_FILL: LinearRgba = LinearRgba::NONE;
|
||||
|
||||
let mut image = Image::new_fill(
|
||||
Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
&INITIAL_FILL.to_u8_array(),
|
||||
TextureFormat::Rgba8Unorm,
|
||||
RenderAssetUsages::MAIN_WORLD,
|
||||
);
|
||||
|
||||
// Create a test pattern
|
||||
|
||||
const TEST_PIXELS: [(u32, u32, LinearRgba); 3] = [
|
||||
(0, 1, LinearRgba::RED),
|
||||
(1, 1, LinearRgba::GREEN),
|
||||
(1, 0, LinearRgba::BLUE),
|
||||
];
|
||||
|
||||
for (x, y, color) in &TEST_PIXELS {
|
||||
image.set_color_at(*x, *y, Color::from(*color)).unwrap();
|
||||
}
|
||||
|
||||
// Grow image
|
||||
image
|
||||
.resize_in_place(Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// After growing, the test pattern should be the same.
|
||||
assert!(matches!(
|
||||
image.get_color_at(0, 0),
|
||||
Ok(Color::LinearRgba(INITIAL_FILL))
|
||||
));
|
||||
for (x, y, color) in &TEST_PIXELS {
|
||||
assert_eq!(
|
||||
image.get_color_at(*x, *y).unwrap(),
|
||||
Color::LinearRgba(*color)
|
||||
);
|
||||
}
|
||||
|
||||
// Pixels in the newly added area should get filled with zeroes.
|
||||
assert!(matches!(
|
||||
image.get_color_at(3, 3),
|
||||
Ok(Color::LinearRgba(GROW_FILL))
|
||||
));
|
||||
|
||||
// Shrink
|
||||
image
|
||||
.resize_in_place(Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Images outside of the new dimensions should be clipped
|
||||
assert!(image.get_color_at(1, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_in_place_array_grow_and_shrink() {
|
||||
use bevy_color::ColorToPacked;
|
||||
|
||||
const INITIAL_FILL: LinearRgba = LinearRgba::BLACK;
|
||||
const GROW_FILL: LinearRgba = LinearRgba::NONE;
|
||||
const LAYERS: u32 = 4;
|
||||
|
||||
let mut image = Image::new_fill(
|
||||
Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: LAYERS,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
&INITIAL_FILL.to_u8_array(),
|
||||
TextureFormat::Rgba8Unorm,
|
||||
RenderAssetUsages::MAIN_WORLD,
|
||||
);
|
||||
|
||||
// Create a test pattern
|
||||
|
||||
const TEST_PIXELS: [(u32, u32, LinearRgba); 3] = [
|
||||
(0, 1, LinearRgba::RED),
|
||||
(1, 1, LinearRgba::GREEN),
|
||||
(1, 0, LinearRgba::BLUE),
|
||||
];
|
||||
|
||||
for z in 0..LAYERS {
|
||||
for (x, y, color) in &TEST_PIXELS {
|
||||
image
|
||||
.set_color_at_3d(*x, *y, z, Color::from(*color))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Grow image
|
||||
image
|
||||
.resize_in_place(Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: LAYERS + 1,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// After growing, the test pattern should be the same.
|
||||
assert!(matches!(
|
||||
image.get_color_at(0, 0),
|
||||
Ok(Color::LinearRgba(INITIAL_FILL))
|
||||
));
|
||||
for z in 0..LAYERS {
|
||||
for (x, y, color) in &TEST_PIXELS {
|
||||
assert_eq!(
|
||||
image.get_color_at_3d(*x, *y, z).unwrap(),
|
||||
Color::LinearRgba(*color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pixels in the newly added area should get filled with zeroes.
|
||||
for z in 0..(LAYERS + 1) {
|
||||
assert!(matches!(
|
||||
image.get_color_at_3d(3, 3, z),
|
||||
Ok(Color::LinearRgba(GROW_FILL))
|
||||
));
|
||||
}
|
||||
|
||||
// Shrink
|
||||
image
|
||||
.resize_in_place(Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Images outside of the new dimensions should be clipped
|
||||
assert!(image.get_color_at_3d(1, 1, 0).is_err());
|
||||
|
||||
// Higher layers should no longer be present
|
||||
assert!(image.get_color_at_3d(0, 0, 1).is_err());
|
||||
|
||||
// Grow layers
|
||||
image
|
||||
.resize_in_place(Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 2,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Pixels in the newly added layer should be zeroes.
|
||||
assert!(matches!(
|
||||
image.get_color_at_3d(0, 0, 1),
|
||||
Ok(Color::LinearRgba(GROW_FILL))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ use bevy_ecs::{
|
||||
};
|
||||
use bevy_platform::collections::HashMap;
|
||||
|
||||
use crate::state::{OnExit, StateTransitionEvent, States};
|
||||
use crate::state::{OnEnter, OnExit, StateTransitionEvent, States};
|
||||
|
||||
fn clear_event_queue<E: Event>(w: &mut World) {
|
||||
if let Some(mut queue) = w.get_resource_mut::<Events<E>>() {
|
||||
@ -18,21 +18,35 @@ fn clear_event_queue<E: Event>(w: &mut World) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum TransitionType {
|
||||
OnExit,
|
||||
OnEnter,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct StateScopedEvents<S: States> {
|
||||
cleanup_fns: HashMap<S, Vec<fn(&mut World)>>,
|
||||
/// Keeps track of which events need to be reset when the state is exited.
|
||||
on_exit: HashMap<S, Vec<fn(&mut World)>>,
|
||||
/// Keeps track of which events need to be reset when the state is entered.
|
||||
on_enter: HashMap<S, Vec<fn(&mut World)>>,
|
||||
}
|
||||
|
||||
impl<S: States> StateScopedEvents<S> {
|
||||
fn add_event<E: Event>(&mut self, state: S) {
|
||||
self.cleanup_fns
|
||||
.entry(state)
|
||||
.or_default()
|
||||
.push(clear_event_queue::<E>);
|
||||
fn add_event<E: Event>(&mut self, state: S, transition_type: TransitionType) {
|
||||
let map = match transition_type {
|
||||
TransitionType::OnExit => &mut self.on_exit,
|
||||
TransitionType::OnEnter => &mut self.on_enter,
|
||||
};
|
||||
map.entry(state).or_default().push(clear_event_queue::<E>);
|
||||
}
|
||||
|
||||
fn cleanup(&self, w: &mut World, state: S) {
|
||||
let Some(fns) = self.cleanup_fns.get(&state) else {
|
||||
fn cleanup(&self, w: &mut World, state: S, transition_type: TransitionType) {
|
||||
let map = match transition_type {
|
||||
TransitionType::OnExit => &self.on_exit,
|
||||
TransitionType::OnEnter => &self.on_enter,
|
||||
};
|
||||
let Some(fns) = map.get(&state) else {
|
||||
return;
|
||||
};
|
||||
for callback in fns {
|
||||
@ -44,12 +58,13 @@ impl<S: States> StateScopedEvents<S> {
|
||||
impl<S: States> Default for StateScopedEvents<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cleanup_fns: HashMap::default(),
|
||||
on_exit: HashMap::default(),
|
||||
on_enter: HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_state_scoped_event<S: States>(
|
||||
fn clear_events_on_exit_state<S: States>(
|
||||
mut c: Commands,
|
||||
mut transitions: EventReader<StateTransitionEvent<S>>,
|
||||
) {
|
||||
@ -65,48 +80,185 @@ fn cleanup_state_scoped_event<S: States>(
|
||||
|
||||
c.queue(move |w: &mut World| {
|
||||
w.resource_scope::<StateScopedEvents<S>, ()>(|w, events| {
|
||||
events.cleanup(w, exited);
|
||||
events.cleanup(w, exited, TransitionType::OnExit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn add_state_scoped_event_impl<E: Event, S: States>(
|
||||
fn clear_events_on_enter_state<S: States>(
|
||||
mut c: Commands,
|
||||
mut transitions: EventReader<StateTransitionEvent<S>>,
|
||||
) {
|
||||
let Some(transition) = transitions.read().last() else {
|
||||
return;
|
||||
};
|
||||
if transition.entered == transition.exited {
|
||||
return;
|
||||
}
|
||||
let Some(entered) = transition.entered.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
c.queue(move |w: &mut World| {
|
||||
w.resource_scope::<StateScopedEvents<S>, ()>(|w, events| {
|
||||
events.cleanup(w, entered, TransitionType::OnEnter);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_events_on_state_transition<E: Event, S: States>(
|
||||
app: &mut SubApp,
|
||||
_p: PhantomData<E>,
|
||||
state: S,
|
||||
transition_type: TransitionType,
|
||||
) {
|
||||
if !app.world().contains_resource::<StateScopedEvents<S>>() {
|
||||
app.init_resource::<StateScopedEvents<S>>();
|
||||
}
|
||||
app.add_event::<E>();
|
||||
app.world_mut()
|
||||
.resource_mut::<StateScopedEvents<S>>()
|
||||
.add_event::<E>(state.clone());
|
||||
app.add_systems(OnExit(state), cleanup_state_scoped_event::<S>);
|
||||
.add_event::<E>(state.clone(), transition_type);
|
||||
match transition_type {
|
||||
TransitionType::OnExit => app.add_systems(OnExit(state), clear_events_on_exit_state::<S>),
|
||||
TransitionType::OnEnter => {
|
||||
app.add_systems(OnEnter(state), clear_events_on_enter_state::<S>)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Extension trait for [`App`] adding methods for registering state scoped events.
|
||||
pub trait StateScopedEventsAppExt {
|
||||
/// Adds an [`Event`] that is automatically cleaned up when leaving the specified `state`.
|
||||
/// Clears an [`Event`] when exiting the specified `state`.
|
||||
///
|
||||
/// Note that event cleanup is ordered ambiguously relative to [`DespawnOnEnterState`](crate::prelude::DespawnOnEnterState)
|
||||
/// and [`DespawnOnExitState`](crate::prelude::DespawnOnExitState) entity
|
||||
/// cleanup and the [`OnExit`] schedule for the target state. All of these (state scoped
|
||||
/// entities and events cleanup, and `OnExit`) occur within schedule [`StateTransition`](crate::prelude::StateTransition)
|
||||
/// Note that event cleanup is ambiguously ordered relative to
|
||||
/// [`DespawnOnExitState`](crate::prelude::DespawnOnExitState) entity cleanup,
|
||||
/// and the [`OnExit`] schedule for the target state.
|
||||
/// All of these (state scoped entities and events cleanup, and `OnExit`)
|
||||
/// occur within schedule [`StateTransition`](crate::prelude::StateTransition)
|
||||
/// and system set `StateTransitionSystems::ExitSchedules`.
|
||||
fn add_state_scoped_event<E: Event>(&mut self, state: impl States) -> &mut Self;
|
||||
fn clear_events_on_exit_state<E: Event>(&mut self, state: impl States) -> &mut Self;
|
||||
|
||||
/// Clears an [`Event`] when entering the specified `state`.
|
||||
///
|
||||
/// Note that event cleanup is ambiguously ordered relative to
|
||||
/// [`DespawnOnEnterState`](crate::prelude::DespawnOnEnterState) entity cleanup,
|
||||
/// and the [`OnEnter`] schedule for the target state.
|
||||
/// All of these (state scoped entities and events cleanup, and `OnEnter`)
|
||||
/// occur within schedule [`StateTransition`](crate::prelude::StateTransition)
|
||||
/// and system set `StateTransitionSystems::EnterSchedules`.
|
||||
fn clear_events_on_enter_state<E: Event>(&mut self, state: impl States) -> &mut Self;
|
||||
}
|
||||
|
||||
impl StateScopedEventsAppExt for App {
|
||||
fn add_state_scoped_event<E: Event>(&mut self, state: impl States) -> &mut Self {
|
||||
add_state_scoped_event_impl(self.main_mut(), PhantomData::<E>, state);
|
||||
fn clear_events_on_exit_state<E: Event>(&mut self, state: impl States) -> &mut Self {
|
||||
clear_events_on_state_transition(
|
||||
self.main_mut(),
|
||||
PhantomData::<E>,
|
||||
state,
|
||||
TransitionType::OnExit,
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
fn clear_events_on_enter_state<E: Event>(&mut self, state: impl States) -> &mut Self {
|
||||
clear_events_on_state_transition(
|
||||
self.main_mut(),
|
||||
PhantomData::<E>,
|
||||
state,
|
||||
TransitionType::OnEnter,
|
||||
);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl StateScopedEventsAppExt for SubApp {
|
||||
fn add_state_scoped_event<E: Event>(&mut self, state: impl States) -> &mut Self {
|
||||
add_state_scoped_event_impl(self, PhantomData::<E>, state);
|
||||
fn clear_events_on_exit_state<E: Event>(&mut self, state: impl States) -> &mut Self {
|
||||
clear_events_on_state_transition(self, PhantomData::<E>, state, TransitionType::OnExit);
|
||||
self
|
||||
}
|
||||
|
||||
fn clear_events_on_enter_state<E: Event>(&mut self, state: impl States) -> &mut Self {
|
||||
clear_events_on_state_transition(self, PhantomData::<E>, state, TransitionType::OnEnter);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::StatesPlugin;
|
||||
use bevy_state::prelude::*;
|
||||
|
||||
#[derive(States, Default, Clone, Hash, Eq, PartialEq, Debug)]
|
||||
enum TestState {
|
||||
#[default]
|
||||
A,
|
||||
B,
|
||||
}
|
||||
|
||||
#[derive(Event, Debug)]
|
||||
struct StandardEvent;
|
||||
|
||||
#[derive(Event, Debug)]
|
||||
struct StateScopedEvent;
|
||||
|
||||
#[test]
|
||||
fn clear_event_on_exit_state() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(StatesPlugin);
|
||||
app.init_state::<TestState>();
|
||||
|
||||
app.add_event::<StandardEvent>();
|
||||
app.add_event::<StateScopedEvent>()
|
||||
.clear_events_on_exit_state::<StateScopedEvent>(TestState::A);
|
||||
|
||||
app.world_mut().send_event(StandardEvent).unwrap();
|
||||
app.world_mut().send_event(StateScopedEvent).unwrap();
|
||||
assert!(!app.world().resource::<Events<StandardEvent>>().is_empty());
|
||||
assert!(!app
|
||||
.world()
|
||||
.resource::<Events<StateScopedEvent>>()
|
||||
.is_empty());
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<NextState<TestState>>()
|
||||
.set(TestState::B);
|
||||
app.update();
|
||||
|
||||
assert!(!app.world().resource::<Events<StandardEvent>>().is_empty());
|
||||
assert!(app
|
||||
.world()
|
||||
.resource::<Events<StateScopedEvent>>()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_event_on_enter_state() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(StatesPlugin);
|
||||
app.init_state::<TestState>();
|
||||
|
||||
app.add_event::<StandardEvent>();
|
||||
app.add_event::<StateScopedEvent>()
|
||||
.clear_events_on_enter_state::<StateScopedEvent>(TestState::B);
|
||||
|
||||
app.world_mut().send_event(StandardEvent).unwrap();
|
||||
app.world_mut().send_event(StateScopedEvent).unwrap();
|
||||
assert!(!app.world().resource::<Events<StandardEvent>>().is_empty());
|
||||
assert!(!app
|
||||
.world()
|
||||
.resource::<Events<StateScopedEvent>>()
|
||||
.is_empty());
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<NextState<TestState>>()
|
||||
.set(TestState::B);
|
||||
app.update();
|
||||
|
||||
assert!(!app.world().resource::<Events<StandardEvent>>().is_empty());
|
||||
assert!(app
|
||||
.world()
|
||||
.resource::<Events<StateScopedEvent>>()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
---
|
||||
title: `StateScoped` renamed to `DespawnOnExitState`
|
||||
pull_requests: [18818]
|
||||
---
|
||||
|
||||
Previously, Bevy provided the `StateScoped` component as a way to despawn an entity when **exiting** a state.
|
||||
|
||||
However, it can also be useful to have the opposite behavior, where an entity is despawned when **entering** a state. This is now possible with the new `DespawnOnEnterState` component.
|
||||
|
||||
To support despawning entities when entering a state, in Bevy 0.17 the `StateScoped` component was renamed to `DespawnOnExitState` and `clear_state_scoped_entities` was renamed to `despawn_entities_on_exit_state`. Replace all references and imports.
|
20
release-content/migration-guides/rename_state_scoped.md
Normal file
20
release-content/migration-guides/rename_state_scoped.md
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
title: Renamed state scoped entities and events
|
||||
pull_requests: [18818, 19435]
|
||||
---
|
||||
|
||||
Previously, Bevy provided the `StateScoped` component and `add_state_scoped_event` method
|
||||
as a way to remove entities/events when **exiting** a state.
|
||||
|
||||
However, it can also be useful to have the opposite behavior,
|
||||
where entities/events are removed when **entering** a state.
|
||||
This is now possible with the new `DespawnOnEnterState` component and `clear_events_on_enter_state` method.
|
||||
|
||||
To support this addition, the previous method and component have been renamed.
|
||||
Also, `clear_event_on_exit_state` no longer adds the event automatically, so you must call `App::add_event` manually.
|
||||
|
||||
| Before | After |
|
||||
|-------------------------------|--------------------------------------------|
|
||||
| `StateScoped` | `DespawnOnExitState` |
|
||||
| `clear_state_scoped_entities` | `despawn_entities_on_exit_state` |
|
||||
| `add_state_scoped_event` | `add_event` + `clear_events_on_exit_state` |
|
Loading…
Reference in New Issue
Block a user