From 00849fd0e6790ea88be4de1c7a11ed431e3d4b99 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:42:18 +1000 Subject: [PATCH] Let shotgun pellets break and push props such as cash registers Bullets reach the client as bullet hit events, which on_entity_bullet_hit turns into dynent damage and impulses. Shotgun pellets (and turret rounds) arrive as pellet records instead, and drain_pellet_fx only drew their tracers and impacts, so a shotgun never broke a cash register, a suitcase or a card reader, and never pushed a prop. Each pellet segment that ends on a surface now strikes the nearest dynent on it the way a bullet does, from the authoritative copy only: drain_pellet_fx sends a DynEntPelletHit, and hit_dyn_ents_from_pellets finds the dynent on the segment and damages and pushes it with the weapon's hit damage. Checked on the Mac on mp_terminal, two short AA-12 bursts at the food-court register: before, the register and card readers stayed whole; after, both broke. Reported from an iPhone match where the AA-12's pellets kicked up dust on suitcases that a SCAR then burst open. Co-Authored-By: Claude Opus 5.5 --- .../src/adapters/anim/dyn_ent_wake.rs | 68 +++++++++++++++++++ .../render_frontend/src/adapters/fx/system.rs | 11 ++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs b/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs index 3b3709b..fd1fa7b 100644 --- a/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs +++ b/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs @@ -52,6 +52,14 @@ pub struct DynEntWakeBroadphase { pub query_ms: f32, } +#[derive(Clone, Copy, Debug, Message)] +pub struct DynEntPelletHit { + pub world: frame::WorldStamp, + pub weapon: u32, + pub start: Vec3, + pub end: Vec3, +} + #[derive(Clone, Copy, Debug)] struct DynEntWakeEntry { entity: Entity, @@ -787,6 +795,59 @@ pub(crate) fn on_entity_bullet_hit( } } +#[allow(clippy::too_many_arguments)] +pub(crate) fn hit_dyn_ents_from_pellets( + mut hits: MessageReader, + weapons: Option>, + catalog: Option>, + mut broadphase: ResMut, + mut instances: Query< + ( + Entity, + &mut WorldDynEntInstance, + &Transform, + &mut Visibility, + ), + With, + >, + mut impulses: MessageWriter, + mut phys: ResMut, + mut host: Option>, + fx: Option>, + scene: Option>, + entity_marks: Res, +) { + let catalog = catalog.as_deref(); + let fx = fx.as_deref(); + let scene = scene.as_deref(); + let marks = entity_marks.as_ref(); + for hit in hits.read() { + let Some((entity, pose, bullet_scale)) = + closest_segment_hit(hit.start, hit.end, catalog, &mut broadphase, &instances) + else { + continue; + }; + let weapons = weapons + .as_deref() + .and_then(|weapons| weapons.for_event(hit.world).ok()); + hit_one_dyn_ent( + entity, + pose, + hit.start, + hit.end, + bullet_scale, + weapon_hit_damage(weapons.as_ref(), hit.weapon), + &mut instances, + &mut impulses, + phys.as_mut(), + host.as_deref_mut(), + fx, + scene, + marks, + ); + } +} + pub(crate) fn on_entity_event_sound( sound: On, weapons: Option>, @@ -917,6 +978,7 @@ fn on_physics_sphere( pub fn register_dyn_ent_wake(app: &mut App) { app.scoped::(frame::MatchScope::Live) + .scoped_message::(frame::MatchScope::Live) .add_observer(on_entity_explosion) .add_observer(on_physics_sphere) .add_observer(on_entity_bullet_hit) @@ -927,6 +989,12 @@ pub fn register_dyn_ent_wake(app: &mut App) { .in_set(frame::InMatch) .in_set(net::ClientSet::Receive), ) + .add_systems( + Update, + hit_dyn_ents_from_pellets + .in_set(frame::InMatch) + .in_set(net::ClientSet::Present), + ) .add_systems( Update, wake_player_overlap diff --git a/crates/render_frontend/src/adapters/fx/system.rs b/crates/render_frontend/src/adapters/fx/system.rs index 22e3b8a..489c68a 100644 --- a/crates/render_frontend/src/adapters/fx/system.rs +++ b/crates/render_frontend/src/adapters/fx/system.rs @@ -3261,12 +3261,13 @@ fn drain_pellet_fx( tracers: Option>, mut tracer_world: ResMut, mut gate: ResMut, - (local, generation, timeline, mut occurrences, verdicts): ( + (local, generation, timeline, mut occurrences, verdicts, mut dyn_ent_hits): ( Res, Res, Res, ResMut, Res, + MessageWriter, ), mut host: ResMut, mut cursor: ResMut, @@ -3280,6 +3281,14 @@ fn drain_pellet_fx( if world != generation.stamp() { continue; } + if domain != net::EntityEventDomain::Predicted && record.normal != [0.0; 3] { + dyn_ent_hits.write(crate::adapters::anim::dyn_ent_wake::DynEntPelletHit { + world, + weapon: record.weapon, + start: Vec3::from_array(record.start), + end: Vec3::from_array(record.end), + }); + } let now = host.0.msec_now; let request = FireFxRequest { world,