From 2b5af49e157aab2982cd45916c25d5fd8e938de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Fri, 18 Sep 2026 11:56:53 +0000 Subject: [PATCH] Implement hitstop shake (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.eirexe.moe/EIREXE/shinespark/pulls/42 Co-authored-by: Álex Román Núñez --- assets/game_settings.ron | 9 +++++- src/debug_tools/save_state.rs | 21 +++++-------- src/fighter/attack/mod.rs | 4 +++ src/fighter/attack/reaction.rs | 35 ++++++++++++--------- src/fighter/mod.rs | 49 +++++++++++++++++++----------- src/fighter/motion.rs | 37 ++++++++++++++++------ src/fighter/state/air_dodge.rs | 6 ++++ src/fighter/state/dash.rs | 6 ++++ src/fighter/state/fall.rs | 6 ++++ src/fighter/state/ground_attack.rs | 6 ++++ src/fighter/state/hitstun.rs | 9 ++++++ src/fighter/state/jump.rs | 12 ++++++++ src/fighter/state/land.rs | 6 ++++ src/fighter/state/mod.rs | 23 ++++++++++++-- src/fighter/state/run.rs | 6 ++++ src/fighter/state/turn.rs | 6 ++++ src/fighter/state/wait.rs | 6 ++++ src/fighter/state/walk.rs | 6 ++++ src/game_settings.rs | 5 +++ 19 files changed, 201 insertions(+), 57 deletions(-) diff --git a/assets/game_settings.ron b/assets/game_settings.ron index 030d522..c691518 100644 --- a/assets/game_settings.ron +++ b/assets/game_settings.ron @@ -17,7 +17,14 @@ knockback_decay_per_frame: "0.051", knockback_launch_velocity_multiplier: "0.03", hitstun_frames_per_unit_knockback: "0.4", - + tumble_bounce_hitstop_threshold: 32, + attack_ground_to_airborne_transition_vertical_angle_threshold: "90.0", + meteor_angle_threshold: "10.0", + visual_shake_frames_per_hitstop_frame: "1.2", + visual_shake_table: [ + -3.0, -1.5, 1.5, 3.0 + ], + attacker_visual_shake_multiplier: 0.4 ), stage_manifest_paths: [ "stages/battlefield/test.stage.ron" diff --git a/src/debug_tools/save_state.rs b/src/debug_tools/save_state.rs index 36769cb..2809915 100644 --- a/src/debug_tools/save_state.rs +++ b/src/debug_tools/save_state.rs @@ -1,22 +1,12 @@ use bevy::prelude::*; use crate::{ - camera::MatchCamera, - fighter::{ - FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, - FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation, - FighterVelocity, Grounded, - animation::FighterAnimationFrame, - attack::{ + camera::MatchCamera, fighter::{ + FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation, FighterVelocity, Grounded, animation::FighterAnimationFrame, attack::{ FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits, FighterSolvedHurtboxes, FighterStaleMoveQueue, - }, - baked_animation::FighterBoneMatrices, - state::FighterState, - }, - input::FighterInput, - player::Player, - stage::line::StageCollision, + }, baked_animation::FighterBoneMatrices, state::FighterState, + }, input::FighterInput, player::Player, stage::line::StageCollision, }; #[derive(Resource, Default)] @@ -48,6 +38,7 @@ struct FighterCheckpoint { damage: FighterDamage, pending_hit_reaction: FighterPendingHitReaction, hitstop: FighterHitstop, + hitstop_shake: FighterHitstopShake, knockback_velocity: FighterKnockbackVelocity, stale_moves: FighterStaleMoveQueue, hitboxes: FighterHitboxes, @@ -146,6 +137,7 @@ pub(super) fn save_or_restore(world: &mut World) { entity.insert(saved.damage); entity.insert(saved.pending_hit_reaction.clone()); entity.insert(saved.hitstop); + entity.insert(saved.hitstop_shake); entity.insert(saved.knockback_velocity); entity.insert(saved.stale_moves.clone()); entity.insert(saved.hitboxes.clone()); @@ -183,6 +175,7 @@ fn capture_fighter(world: &World, entity: Entity) -> Option { damage: *fighter.get::()?, pending_hit_reaction: fighter.get::()?.clone(), hitstop: *fighter.get::()?, + hitstop_shake: *fighter.get::()?, knockback_velocity: *fighter.get::()?, stale_moves: fighter.get::()?.clone(), hitboxes: fighter.get::()?.clone(), diff --git a/src/fighter/attack/mod.rs b/src/fighter/attack/mod.rs index 6d9aa90..8018a25 100644 --- a/src/fighter/attack/mod.rs +++ b/src/fighter/attack/mod.rs @@ -82,6 +82,10 @@ impl HitStop { Self(frames) } + + pub fn calculate_shake_frames(&self, game_settings: &GameSettings) -> u32 { + (FGi32::from_num(self.0) * game_settings.fighter_common.visual_shake_frames_per_hitstop_frame).floor().to_num() + } } #[derive(Clone, Copy, Debug, Serialize, Deserialize)] diff --git a/src/fighter/attack/reaction.rs b/src/fighter/attack/reaction.rs index 10e827d..a6f0504 100644 --- a/src/fighter/attack/reaction.rs +++ b/src/fighter/attack/reaction.rs @@ -5,15 +5,8 @@ use fixed_math::SinCos; use super::*; use crate::{ fighter::{ - Fighter, FighterFacingDirection, FighterHitboxes, FighterHitstop, - FighterPendingHitReaction, FighterStaleMoveQueue, errors::FighterErrors, - manifest::FighterManifest, - }, - game_settings::GameSettings, - math::{int::FGi32, vec::FGVec2}, - player::Player, - schedule::GameplaySet, - scripting::FighterAttackScript, + Fighter, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterPendingHitReaction, FighterStaleMoveQueue, errors::FighterErrors, manifest::FighterManifest, state::FighterState, + }, game_settings::GameSettings, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, }; pub fn resolve_fighter_hits( @@ -24,8 +17,9 @@ pub fn resolve_fighter_hits( &FighterHitboxes, &Player, &mut FighterHitstop, + &mut FighterHitstopShake, )>, - mut receiver: Query<(&mut FighterDamage, &Fighter, &mut FighterPendingHitReaction)>, + mut receiver: Query<(&mut FighterDamage, &Fighter, &mut FighterPendingHitReaction, &FighterState)>, attack_scripts: Res>, game_settings: Res, fighter_manifests: Res>, @@ -40,7 +34,7 @@ pub fn resolve_fighter_hits( .position(|hit| hit.defender != defender) .map_or(hits.len(), |offset| hit_offset + offset); - let Ok((mut receiver_damage, fighter, mut pending_reaction)) = receiver.get_mut(defender) + let Ok((mut receiver_damage, fighter, mut pending_reaction, receiver_state)) = receiver.get_mut(defender) else { hit_offset = next_defender; continue; @@ -49,7 +43,7 @@ pub fn resolve_fighter_hits( let damage_data = hits[hit_offset..next_defender] .iter() .filter_map(|hit| { - let Ok((stale_move_queue, facing_dir, _hitboxes, player, _)) = + let Ok((stale_move_queue, facing_dir, _hitboxes, player, _, _)) = attacker.get(hit.attacker) else { return None; @@ -115,10 +109,23 @@ pub fn resolve_fighter_hits( receiver_damage.0 += total_damage_received_this_frame; for (attacker_entity, _, reaction) in &reactions { - let Ok((.., mut hitstop)) = attacker.get_mut(*attacker_entity) else { + let Ok((.., mut hitstop, mut hitstop_shake)) = attacker.get_mut(*attacker_entity) else { continue; }; - hitstop.remaining_frames = reaction.hitstop_frames.max(hitstop.remaining_frames); + + if hitstop.remaining_frames < reaction.hitstop_frames.0 { + let shake_kind = if receiver_state.is_visually_grounded() { + FighterHitstopShakeKind::Horizontal + } else { + FighterHitstopShakeKind::Vertical + }; + hitstop.remaining_frames = reaction.hitstop_frames.0; + let shake_frames = reaction.hitstop_frames.calculate_shake_frames(&game_settings); + hitstop_shake.total_frames = shake_frames; + hitstop_shake.remaining_frames = shake_frames; + hitstop_shake.kind = shake_kind; + hitstop_shake.role = FighterHitstopShakeRole::Attacker; + } } pending_reaction.0 = reactions diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 0d338a5..0a73c85 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -33,24 +33,13 @@ pub use motion::{ }; use crate::{ - AppState, - fighter::{ + AppState, fighter::{ attack::{ AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes, FighterStaleMoveQueue, - }, - baked_animation::{FighterBoneIndexMap, FighterBoneMatrices}, - manifest::FighterManifest, - state::{FighterState, fall::FallState}, - visual::FighterAnimations, - }, - input::FighterInput, - math::{int::FGi32, vec::FGVec2}, - player::Player, - schedule::GameplaySet, - scripting::FighterAttackScript, - stage::line::StageCollision, + }, baked_animation::{FighterBoneIndexMap, FighterBoneMatrices}, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, + }, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision, }; use state::state_interrupt_system; @@ -65,7 +54,30 @@ pub enum FighterFacingDirection { Right, } +#[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect, Default)] +pub enum FighterHitstopShakeKind { + #[default] + Horizontal, + Vertical +} + +#[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect, Default)] +pub enum FighterHitstopShakeRole { + #[default] + Attacker, + Receiver +} + #[derive(Component, Default, Copy, Clone, Hash)] +pub struct FighterHitstopShake { + pub remaining_frames: u32, + pub total_frames: u32, + pub kind: FighterHitstopShakeKind, + pub role: FighterHitstopShakeRole +} + +#[derive(Component, Default, Copy, Clone, Hash)] +#[require(FighterHitstopShake)] pub struct FighterHitstop { pub remaining_frames: u32, } @@ -120,7 +132,8 @@ pub struct FighterVisual; FighterDamage, FighterPendingHitReaction, FighterHitstop, - FighterKnockbackVelocity + FighterKnockbackVelocity, + FighterTranslation, )] pub struct Fighter { pub manifest: Handle, @@ -230,6 +243,7 @@ impl Plugin for FighterPlugin { .rollback_component_with_clone::() .rollback_component_with_clone::() .rollback_component_with_clone::() + .rollback_component_with_clone::() .checksum_component_with_hash::() .rollback_component_with_clone::() .checksum_component_with_hash::() @@ -299,8 +313,9 @@ pub fn spawn_fighter( .id() } -pub fn decay_hitstop(query: Query<&mut FighterHitstop>) { - for mut hitstop in query { +pub fn decay_hitstop(query: Query<(&mut FighterHitstop, &mut FighterHitstopShake)>) { + for (mut hitstop, mut hitstop_shake) in query { hitstop.remaining_frames = hitstop.remaining_frames.saturating_sub(1); + hitstop_shake.remaining_frames = hitstop_shake.remaining_frames.saturating_sub(1); } } diff --git a/src/fighter/motion.rs b/src/fighter/motion.rs index 32454d4..f1a0657 100644 --- a/src/fighter/motion.rs +++ b/src/fighter/motion.rs @@ -5,6 +5,8 @@ use bevy::prelude::*; use bevy_ggrs::GgrsFrameTiming; use crate::debug_tools::PresentationMotionMode; +use crate::fighter::{FighterHitstopShake}; +use crate::game_settings::GameSettings; use crate::{ fighter::{FighterECB, FighterFacingDirection, baked_animation::FixedMat4}, math::{int::FGi32, vec::FGVec2, vec3::FGVec3}, @@ -58,13 +60,15 @@ pub fn apply_fighter_translation_to_visuals( &FighterTranslation, &FighterPreviousTranslation, &FighterECB, + &FighterHitstopShake, &mut Transform, )>, timing: Res, debug_settings: Res, + game_settings: Res, ) { let overstep_fraction = timing.overstep_fraction(); - for (translation, previous_translation, ecb, mut transform) in query { + for (translation, previous_translation, ecb, hitstop_shake, mut transform) in query { let translation = sample_presentation_translation( *previous_translation, *translation, @@ -77,6 +81,28 @@ pub fn apply_fighter_translation_to_visuals( translation.y + bottom.y.to_num::(), 0.0, ); + + if hitstop_shake.remaining_frames > 0 && hitstop_shake.total_frames > 0 && !game_settings.fighter_common.visual_shake_table.is_empty() { + let total = hitstop_shake.total_frames as f32; + let curr = hitstop_shake.remaining_frames as f32; + let elapsed = 1.0 - curr / total; + + + let access_index = (hitstop_shake.remaining_frames as usize) % game_settings.fighter_common.visual_shake_table.len(); + let shake_multiplier = match hitstop_shake.role { + super::FighterHitstopShakeRole::Attacker => game_settings.fighter_common.attacker_visual_shake_multiplier, + super::FighterHitstopShakeRole::Receiver => 1.0, + }; + let shake_amount: f32 = 1.0 - EaseFunction::QuinticIn.sample(elapsed).unwrap_or_default(); + let shake_amount = game_settings.fighter_common.visual_shake_table[access_index] * shake_amount * shake_multiplier; + + let shake_offset = match hitstop_shake.kind { + super::FighterHitstopShakeKind::Horizontal => Vec3::new(shake_amount, 0.0, 0.0), + super::FighterHitstopShakeKind::Vertical => Vec3::new(0.0, shake_amount, 0.0), + }; + + transform.translation += shake_offset; + } } } @@ -85,22 +111,15 @@ pub fn apply_air_motion( &FighterVelocity, &mut FighterTranslation, &mut FighterPreviousTranslation, - &mut Transform, )>, _time: Res>, ) { - for (velocity, mut translation, mut prev_translation, _transform) in fighters { + for (velocity, mut translation, mut prev_translation) in fighters { prev_translation.0 = translation.0; translation.0 += velocity.0; } } -pub fn copy_fighter_transform_to_visuals(fighters: Query<(&FighterTranslation, &mut Transform)>) { - for (translation, mut transform) in fighters { - transform.translation = Vec3::new(translation.x.to_num(), translation.y.to_num(), 0.0); - } -} - pub fn fighter_movement() {} pub fn snapshot_fighter_motion( diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index 1eb5745..787c7d7 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -49,6 +49,12 @@ impl FighterStateImpl for AirDodgeState { state_context.velocity.0 = self.direction * state_context.fighter_attributes.air_dodge_velocity; } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + false + } } pub fn check_input(state_context: &InterruptContext) -> Option { diff --git a/src/fighter/state/dash.rs b/src/fighter/state/dash.rs index 3ec424e..6ea001a 100644 --- a/src/fighter/state/dash.rs +++ b/src/fighter/state/dash.rs @@ -108,6 +108,12 @@ impl FighterStateImpl for DashState { ) -> Option { ground::collision_interrupt(state_context, &mut self.ground_common, true) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } fn check_smash_input_with_dir(state_context: &InterruptContext) -> Option { diff --git a/src/fighter/state/fall.rs b/src/fighter/state/fall.rs index 46c812a..3249b43 100644 --- a/src/fighter/state/fall.rs +++ b/src/fighter/state/fall.rs @@ -28,6 +28,12 @@ impl FighterStateImpl for FallState { crate::fighter::state::land::LandingType::Normal, ) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + false + } } pub fn check_input(state_context: &InterruptContext) -> Option { diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index 119eb8a..3005266 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -67,6 +67,12 @@ impl FighterStateImpl for GroundAttackState { ) -> Option { None } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } pub fn check_interrupt( diff --git a/src/fighter/state/hitstun.rs b/src/fighter/state/hitstun.rs index 5112729..ad1dd35 100644 --- a/src/fighter/state/hitstun.rs +++ b/src/fighter/state/hitstun.rs @@ -95,4 +95,13 @@ impl FighterStateImpl for HitstunState { } } } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + match self.hitstop_kind { + HitstopStateKind::Grounded(_) => true, + HitstopStateKind::Airborne => false, + } + } } diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 1c773c8..ec6f7f1 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -96,6 +96,12 @@ impl FighterStateImpl for JumpSquatState { state_context.play_animation(AnimKind::JumpSquat, false); state_context.input.clear_command(FighterCommands::Jump); } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } impl FighterStateImpl for JumpState { @@ -174,6 +180,12 @@ impl FighterStateImpl for JumpState { crate::fighter::state::land::LandingType::Normal, ) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + false + } } pub fn check_input( diff --git a/src/fighter/state/land.rs b/src/fighter/state/land.rs index 7989171..16255a8 100644 --- a/src/fighter/state/land.rs +++ b/src/fighter/state/land.rs @@ -69,4 +69,10 @@ impl FighterStateImpl for LandingState { } }; } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 3ef6bec..2a8a36a 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -16,8 +16,7 @@ use crate::fighter::manifest::FighterManifest; use crate::fighter::state::hitstun::HitstunState; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ - Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, - FighterPreviousTranslation, FighterTranslation, FighterVelocity, + Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::input::FighterInput; @@ -66,6 +65,10 @@ macro_rules! fighter_states { pub fn check_collision_interrupt(&mut self, ctx: &mut CollisionContext) -> Option { match self { $(Self::$variant(s) => s.check_collision_interrupt(ctx)),* } } + + pub fn is_visually_grounded(&self) -> bool { + match self { $(Self::$variant(s) => s.is_visually_grounded()),* } + } } $(impl From<$ty> for FighterState { @@ -91,6 +94,10 @@ pub trait FighterStateImpl: Sized { ) -> Option { None } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool; } pub struct AnimationStatus { @@ -320,6 +327,7 @@ pub struct StatefulFighterQuery { pub attack_script_assets: &'static FighterAttackScriptAssets, pub pending_hit_reaction: &'static mut FighterPendingHitReaction, pub hitstop: &'static mut FighterHitstop, + pub hitstop_shake: &'static mut FighterHitstopShake, } pub fn state_update_system( @@ -384,6 +392,7 @@ pub fn state_interrupt_system( attack_script_assets, mut pending_hit_reaction, mut hitstop, + mut hitstop_shake, } = fighter; let manifest = fighter_manifests .get(&fighter.manifest) @@ -391,6 +400,16 @@ pub fn state_interrupt_system( let new_state = if let Some(reaction) = pending_hit_reaction.take() { hitstop.remaining_frames = *reaction.hitstop_frames; + let shake_frames = reaction.hitstop_frames.calculate_shake_frames(&game_settings); + hitstop_shake.role = FighterHitstopShakeRole::Receiver; + hitstop_shake.total_frames = shake_frames; + hitstop_shake.remaining_frames = shake_frames; + hitstop_shake.kind = if state.is_visually_grounded() { + FighterHitstopShakeKind::Horizontal + } else { + FighterHitstopShakeKind::Vertical + }; + let mut hitstop_kind = match &*state { FighterState::Wait(value) => { hitstun::HitstopStateKind::Grounded(value.grounded_common.clone()) diff --git a/src/fighter/state/run.rs b/src/fighter/state/run.rs index afb40d6..973b633 100644 --- a/src/fighter/state/run.rs +++ b/src/fighter/state/run.rs @@ -64,6 +64,12 @@ impl FighterStateImpl for RunState { ) -> Option { ground::collision_interrupt(state_context, &mut self.grounded_common, true) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } pub fn check_input( diff --git a/src/fighter/state/turn.rs b/src/fighter/state/turn.rs index 05a10b3..0460131 100644 --- a/src/fighter/state/turn.rs +++ b/src/fighter/state/turn.rs @@ -56,6 +56,12 @@ impl FighterStateImpl for TurnState { ) -> Option { ground::collision_interrupt(state_context, &mut self.grounded_common, false) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } pub fn check_input( diff --git a/src/fighter/state/wait.rs b/src/fighter/state/wait.rs index d4327d8..962babf 100644 --- a/src/fighter/state/wait.rs +++ b/src/fighter/state/wait.rs @@ -46,6 +46,12 @@ impl FighterStateImpl for WaitState { ) -> Option { ground::collision_interrupt(state_context, &mut self.grounded_common, false) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } pub fn check_input( diff --git a/src/fighter/state/walk.rs b/src/fighter/state/walk.rs index dc8814e..d3ba75d 100644 --- a/src/fighter/state/walk.rs +++ b/src/fighter/state/walk.rs @@ -56,6 +56,12 @@ impl FighterStateImpl for WalkState { ) -> Option { ground::collision_interrupt(state_context, &mut self.grounded_common, false) } + + fn is_visually_grounded<'a>( + &'a self, + ) -> bool { + true + } } pub fn check_input( diff --git a/src/game_settings.rs b/src/game_settings.rs index fb76538..568c30b 100644 --- a/src/game_settings.rs +++ b/src/game_settings.rs @@ -31,6 +31,11 @@ pub struct FighterSettingsCommon { /// Angle diff from straight up under which the attack will force an airborne state pub attack_ground_to_airborne_transition_vertical_angle_threshold: FGi32, + pub visual_shake_frames_per_hitstop_frame: FGi32, + // offsets applied when the fighter is in hitstop + pub visual_shake_table: Vec, + /// The attacker will always get less hitstop + pub attacker_visual_shake_multiplier: f32, } #[derive(Reflect, Serialize, Deserialize, Clone)] #[reflect(opaque)]