Firework rockets: honour Silent, post EXPLODE, count block launches

`FireworkRocketEntity.explode` posts `GameEvent.EXPLODE` from `getOwner()` at
the rocket before the damage, and the entity event passes `!isSilent()` to
`createFireworks`, which gates only the blast and the twinkle of
`FireworkParticles.Starter`, so a silent rocket keeps its sparks and flash.
A rocket launched from a block face also counts as a use, as
`ItemStack.useOn` does for `FireworkRocketItem.useOn`'s SUCCESS.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
This commit is contained in:
committed 2026-11-10 02:05:57 +01:00
1 parent 1453e0e614
commit 188817342b
5 files changed
+272 -16

No files matched your search

+12 -12
View File
@@ -3630,13 +3630,14 @@ impl ParticleEngine {
self.sprite_from_age(&mut q);
q
}
/// ClientLevel.createFireworks: a Starter (or poofs without explosions).
/// ClientLevel.createFireworks: a Starter (or poofs without explosions) with `playSound`.
pub fn fireworks(
&mut self,
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
camera: [f64; 3],
play_sound: bool,
) {
if explosions.is_empty() {
for _ in 0..self.random.next_int_bound(3).unwrap_or(0) + 2 {
@@ -3660,17 +3661,17 @@ impl ParticleEngine {
life: 0,
lifetime,
twinkle_delay: twinkle,
far,
sound: play_sound.then_some(far),
});
}
fn tick_starters(&mut self) {
for mut s in std::mem::take(&mut self.starters) {
if s.life == 0 {
if s.life == 0 && s.sound.is_some() {
let large = s.explosions.len() >= 3
|| s.explosions
.iter()
.any(|e| e.shape == FireworkShape::LargeBall);
let sound = match (large, s.far) {
let sound = match (large, s.sound == Some(true)) {
(true, true) => "minecraft:entity.firework_rocket.large_blast_far",
(true, false) => "minecraft:entity.firework_rocket.large_blast",
(false, true) => "minecraft:entity.firework_rocket.blast_far",
@@ -3713,16 +3714,13 @@ impl ParticleEngine {
(c & 255) as f32 / 255.0,
((c >> 24) & 255) as f32 / 255.0,
];
self.create(
&ParticleOptions::colored(ParticleType::Flash, rgba),
s.at,
[0.0; 3],
);
let flash = ParticleOptions::colored(ParticleType::Flash, rgba);
self.create(&flash, s.at, [0.0; 3]);
}
s.life += 1;
if s.life > s.lifetime {
if s.twinkle_delay {
let sound = if s.far {
if s.twinkle_delay && s.sound.is_some() {
let sound = if s.sound == Some(true) {
"minecraft:entity.firework_rocket.twinkle_far"
} else {
"minecraft:entity.firework_rocket.twinkle"
@@ -5100,7 +5098,9 @@ struct Starter {
life: i32,
lifetime: i32,
twinkle_delay: bool,
far: bool,
/// `FireworkParticles.Starter.playSound`, with `isFarAwayFromCamera` when
/// it plays: `None` for a silent rocket.
sound: Option<bool>,
}
const STAR: [[f64; 2]; 6] = [
[0.0, 1.0],
@@ -38,7 +38,7 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
twinkle: false,
};
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera, true);
}
for &(ty, count, center, delta, speed) in types {
let options = ParticleOptions::simple(ty);
+35 -2
View File
@@ -325,6 +325,8 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// `ItemStack.useOn` awards ITEM_USED for `FireworkRocketItem.useOn`'s SUCCESS.
self.award_stat("used", "minecraft:firework_rocket", 1);
self.swing_arm();
true
}
@@ -550,6 +552,8 @@ impl Game<'_> {
host.hub
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
}
// `this.gameEvent(GameEvent.EXPLODE, this.getOwner())`, before the blast.
self.rocket_explode_event(r.owner, r.guest, at);
if !r.explosions.is_empty() {
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
let from = r.position;
@@ -627,9 +631,34 @@ impl Game<'_> {
let camera = self.eye_position().unwrap_or(at);
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
.fireworks(at, v, r.explosions.clone(), camera, !r.silent);
self.native_firework_explode(r.id, at, r.explosions.len());
}
/// `FireworkRocketEntity.explode`'s game event: EXPLODE at the rocket, from
/// its owner (`Projectile.getOwner`), a guest's rocket as theirs.
fn rocket_explode_event(&mut self, mut owner: Option<Who>, guest: Option<u32>, at: [f64; 3]) {
if let Some(conn) = guest.filter(|conn| self.lan_acting != Some(*conn)) {
let posted = if let Some(mut host) = self.lan.take() {
let posted = self
.as_guest(&mut host, conn, |g| {
let ctx = g.who_context(owner, None);
g.post_game_event("minecraft:explode", at, ctx);
})
.is_some();
self.lan = Some(host);
posted
} else {
false
};
if posted {
return;
}
// An owner who left cannot resolve to the host: no source entity.
owner = None;
}
let ctx = self.who_context(owner, None);
self.post_game_event("minecraft:explode", at, ctx);
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
let _attacker = self.knockback_scope(if attacker == Some(Who::Player) {
self.player.as_ref().map_or(0, |p| p.entity_id)
@@ -730,7 +759,7 @@ impl Game<'_> {
let at = [r.position.x, r.position.y, r.position.z];
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at), !r.silent);
}
}
/// `dealExplosionDamage` on a player that is not the rocket's
@@ -840,6 +869,10 @@ fn use_on_passes(fall_flying: bool) -> bool {
fall_flying
}
#[cfg(test)]
#[path = "fireworks_events_tests.rs"]
mod events_tests;
#[cfg(test)]
mod tests {
/// A flying rocket's vertices carry its own id for frame generation's and a temporal
@@ -0,0 +1,223 @@
//! `FireworkRocketEntity.explode`'s game event and sound gate, and the
//! `ITEM_USED` an `useOn` counts. Prepared resources only.
use super::Game;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
items::ItemStack,
mobs::Who,
particles::{FireworkExplosion, FireworkShape, ParticleLevel},
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
use std::sync::Arc;
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap(),
);
game.world.random = LegacyRandom::new(114);
test(&mut game);
}
/// One `small_ball` that twinkles, as `/summon` reads it: a `Starter` of 16
/// ticks that plays a blast first and a twinkle last.
fn twinkling() -> FireworkExplosion {
FireworkExplosion {
shape: FireworkShape::SmallBall,
colors: vec![0xFF5555],
fade: Vec::new(),
trail: true,
twinkle: true,
}
}
fn tick_particles(game: &mut Game<'_>) {
let level = ParticleLevel {
world: &game.world,
facts: game.res.facts(),
collision: game.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
game.particles.tick(&level);
}
/// `FireworkParticles.Starter.playSound`: a silent rocket launches quietly (as
/// it already did) and bursts quietly too, drawing its sparks and flash only.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_rocket_bursts_without_blast_or_twinkle() {
with_game(|game| {
let stack = ItemStack::new("minecraft:firework_rocket", 1);
let mut launching = game.new_rocket(Vec3::new(6.5, 201.5, 10.5), &stack, false);
launching.silent = true;
launching.lifetime = 100;
game.rockets.push(launching);
game.audio.capture = Some(Vec::new());
game.tick_rockets();
let heard = game.audio.capture.take().unwrap_or_default();
assert!(
heard
.iter()
.all(|s| s.event != "minecraft:entity.firework_rocket.launch"),
"a silent rocket launches quietly: {heard:?}"
);
game.rockets.clear();
let (quiet_at, loud_at) = ([6.5, 201.5, 10.5], [12.5, 201.5, 10.5]);
let mut quiet = game.new_rocket(
Vec3::new(quiet_at[0], quiet_at[1], quiet_at[2]),
&stack,
false,
);
quiet.silent = true;
quiet.explosions = vec![twinkling()];
let mut loud =
game.new_rocket(Vec3::new(loud_at[0], loud_at[1], loud_at[2]), &stack, false);
loud.explosions = vec![twinkling()];
game.explode_rocket(&quiet);
game.explode_rocket(&loud);
game.particles.sounds.clear();
for _ in 0..20 {
tick_particles(game);
}
let bursts: Vec<(&str, [f64; 3])> = game
.particles
.sounds
.iter()
.filter(|s| s.0.starts_with("minecraft:entity.firework_rocket."))
.map(|s| (s.0, s.1))
.collect();
assert_eq!(
bursts,
vec![
("minecraft:entity.firework_rocket.blast", loud_at),
("minecraft:entity.firework_rocket.twinkle", loud_at),
],
"only the loud rocket bangs and twinkles, both at its own position"
);
assert!(
!game.particles.is_empty(),
"the silent rocket still drew its sparks and flash"
);
});
}
/// Recording of `Game::post_game_event`, as the sculk tests read it.
fn start_events() {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
}
fn take_explosions() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| {
std::mem::take(e.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|e| e.0 == "minecraft:explode")
.collect()
})
}
fn stop_events() {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = None);
}
/// `FireworkRocketEntity.explode`: `gameEvent(GameEvent.EXPLODE, getOwner())` at
/// the rocket, once, with and without explosions; a rocket nobody owns (a
/// dispenser's) has no source entity.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_detonating_rocket_posts_one_explode_from_its_owner() {
with_game(|game| {
let stack = ItemStack::new("minecraft:firework_rocket", 1);
let mut burst = game.new_rocket(Vec3::new(8.5, 202.0, 8.5), &stack, false);
burst.explosions = vec![twinkling()];
burst.owner = Some(Who::Player);
let mut plain = game.new_rocket(Vec3::new(9.5, 202.0, 8.5), &stack, false);
plain.owner = Some(Who::Player);
let mut stray = game.new_rocket(Vec3::new(10.5, 202.0, 8.5), &stack, false);
stray.explosions = vec![twinkling()];
start_events();
game.explode_rocket(&burst);
assert_eq!(
take_explosions(),
vec![("minecraft:explode", [8.5, 202.0, 8.5], Some(Who::Player))],
"the burst is heard once, at the rocket, from the player who shot it"
);
game.explode_rocket(&plain);
assert_eq!(
take_explosions(),
vec![("minecraft:explode", [9.5, 202.0, 8.5], Some(Who::Player))],
"a rocket with no explosions still posts it"
);
game.explode_rocket(&stray);
assert_eq!(
take_explosions(),
vec![("minecraft:explode", [10.5, 202.0, 8.5], None)],
"`getOwner()` is null: no source entity"
);
stop_events();
});
}
/// `ItemStack.useOn` awards `ITEM_USED` for `FireworkRocketItem.useOn`'s
/// SUCCESS; `FireworkRocketItem.use` while gliding awards its own.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn launching_from_a_block_and_while_gliding_use_the_rocket_up() {
with_game(|game| {
game.creative = false;
let stack = ItemStack::new("minecraft:firework_rocket", 1);
let hit = super::Hit {
pos: [2, 199, 2],
face: [0, 1, 0],
point: [2.5, 200.0, 2.5],
};
for used in 1..=5 {
game.inventory[game.selected] = Some(stack.clone());
assert!(game.launch_firework(&hit, &stack), "a rocket goes up");
assert_eq!(
game.stats.get("used", "minecraft:firework_rocket"),
used,
"each right-click on the block counts one use"
);
}
game.player.as_mut().unwrap().fall_flying = true;
game.inventory[game.selected] = Some(stack.clone());
assert!(game.boost_firework(&stack), "the gliding boost launches");
assert_eq!(game.stats.get("used", "minecraft:firework_rocket"), 6);
});
}
@@ -84,7 +84,7 @@ impl Game<'_> {
}
let camera = self.eye_position().unwrap_or(at);
self.particles
.fireworks(at, velocity, explosions.to_vec(), camera);
.fireworks(at, velocity, explosions.to_vec(), camera, true);
true
}
}