mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 17:18:25 -10:00
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:
1 parent
1453e0e614
commit
188817342b
5 files changed
+272
-16
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user