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 <noreply@anthropic.com>
This commit is contained in:
JohnM
2026-10-13 20:42:18 +10:00
parent 0bbab05479
commit 00849fd0e6
2 changed files with 78 additions and 1 deletions
@@ -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<DynEntPelletHit>,
weapons: Option<Res<PreparedWeapons>>,
catalog: Option<Res<asset_world::MapXModelSceneCatalog>>,
mut broadphase: ResMut<DynEntWakeBroadphase>,
mut instances: Query<
(
Entity,
&mut WorldDynEntInstance,
&Transform,
&mut Visibility,
),
With<DynEntModelEntity>,
>,
mut impulses: MessageWriter<DynEntPhysImpulse>,
mut phys: ResMut<DynEntPhysWorld>,
mut host: Option<ResMut<HostFxSystem>>,
fx: Option<Res<PreparedFxCatalog>>,
scene: Option<Res<WorldScene>>,
entity_marks: Res<EntityMarks>,
) {
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<EntityEventSound>,
weapons: Option<Res<PreparedWeapons>>,
@@ -917,6 +978,7 @@ fn on_physics_sphere(
pub fn register_dyn_ent_wake(app: &mut App) {
app.scoped::<DynEntWakeBroadphase>(frame::MatchScope::Live)
.scoped_message::<DynEntPelletHit>(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
@@ -3261,12 +3261,13 @@ fn drain_pellet_fx(
tracers: Option<Res<PreparedTracers>>,
mut tracer_world: ResMut<TracerWorld>,
mut gate: ResMut<TracerDrawGate>,
(local, generation, timeline, mut occurrences, verdicts): (
(local, generation, timeline, mut occurrences, verdicts, mut dyn_ent_hits): (
Res<LocalPresentClient>,
Res<frame::WorldGeneration>,
Res<net::EntityEventCursor>,
ResMut<PresentedFireFx>,
Res<net::FireVerdictState>,
MessageWriter<crate::adapters::anim::dyn_ent_wake::DynEntPelletHit>,
),
mut host: ResMut<HostFxSystem>,
mut cursor: ResMut<FxJournalCursor>,
@@ -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,