Merge pull request #3500 from spidercraft/fix/firework-gaps

🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-29 12:54:07 +01:00
commit 5e391eb673
6 files changed
+244 -27

No files matched your search

+27
View File
@@ -1692,6 +1692,33 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
Some(p)
}
/// `FireworkRocketEntity.readAdditionalSaveData`: Life, LifeTime (0 when
/// absent: a bare `/summon` explodes at once), FireworksItem and
/// ShotAtAngle, with `Projectile`'s Owner and LeftOwner.
pub struct RocketNbt {
pub position: Vec3,
pub motion: Vec3,
pub life: i32,
pub lifetime: i32,
pub item: Option<crate::items::ItemStack>,
pub angled: bool,
pub owner: Option<Who>,
pub left_owner: bool,
}
pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<RocketNbt> {
let owner = uuid_from_tag(tag.get("Owner"));
Some(RocketNbt {
position: position(tag)?,
motion: motion(tag),
life: number(tag.get("Life")).unwrap_or(0) as i32,
lifetime: number(tag.get("LifeTime")).unwrap_or(0) as i32,
item: tag.get("FireworksItem").and_then(item_from_nbt),
angled: flag(tag, "ShotAtAngle"),
owner: (player.is_some() && owner == player).then_some(Who::Player),
left_owner: flag(tag, "LeftOwner"),
})
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
+4 -1
View File
@@ -1929,7 +1929,10 @@ impl Game<'_> {
fn spawned_mark(&self) -> (u32, usize) {
(
self.next_entity,
self.tnts.len() + self.falling.len() + self.creatures.projectiles.len(),
self.tnts.len()
+ self.falling.len()
+ self.creatures.projectiles.len()
+ self.rockets.len(),
)
}
/// BlockStateArgument -> registry state.
+1 -1
View File
@@ -203,7 +203,7 @@ impl Game<'_> {
power,
1.0,
);
self.launch_angled_rocket(at, shot.velocity, ammo);
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
}
if !rocket {
let kind = ProjectileKind::Arrow {
+1 -1
View File
@@ -987,7 +987,7 @@ impl Game<'_> {
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_angled_rocket(at, shot.velocity, &one);
self.launch_angled_rocket(at, shot.velocity, &one, None);
}
_ => {
let kind = match item.as_str() {
+206 -24
View File
@@ -7,10 +7,12 @@ use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
collisions::SceneCollisions,
entity_nbt::RocketNbt,
items::ItemStack,
mesher::Vertex,
mobs::{Damage, DamageKind},
mobs::{Damage, DamageKind, Species, Who},
particles::{FireworkExplosion, ParticleOptions, ParticleType},
projectiles::ProjectileWorld,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -30,9 +32,14 @@ pub(super) struct Rocket {
pub(super) explosions: Vec<FireworkExplosion>,
/// Boosting the player (attachedToEntity).
pub(super) attached: bool,
/// On a server, the player it boosts when not this game's own: a guest's
/// connection.
/// On a server, the guest (by connection) who launched it, or whom it
/// boosts: `owner`'s `Who::Player` then means them, not the host.
pub(super) guest: Option<u32>,
/// `Projectile.getOwner`: the player who launched it, credited with
/// what its blast hurts.
pub(super) owner: Option<Who>,
/// `Projectile.leftOwner`: until then it can't hit its owner.
left_owner: bool,
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
/// boosting happens when it is the local player.
pub(super) attached_to: Option<u32>,
@@ -57,11 +64,36 @@ impl Rocket {
explosions,
attached: false,
guest: None,
owner: None,
left_owner: false,
attached_to: None,
angled: false,
item,
}
}
/// A rocket loaded from its NBT (`/summon`, a saved chunk).
pub(super) fn from_nbt(r: RocketNbt) -> Self {
let item = r
.item
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
let (_, explosions) = fireworks_of(&item);
Self {
id: 0,
position: r.position,
previous: r.position,
velocity: r.motion,
life: r.life,
lifetime: r.lifetime,
explosions,
attached: false,
guest: None,
owner: r.owner,
left_owner: r.left_owner,
attached_to: None,
angled: r.angled,
item,
}
}
/// One tick of a server's client: the rocket flies on by itself between
/// the server's updates (a rocket that follows an entity is placed by the
/// caller).
@@ -72,6 +104,19 @@ impl Rocket {
}
self.life += 1;
}
/// The move of a rocket attached to no entity: sped up and rising, or an
/// angled one's own motion.
fn next_motion(&self) -> Vec3 {
if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
}
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
@@ -85,15 +130,7 @@ impl Rocket {
self.position.z + h,
),
);
let wanted = if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
};
let wanted = self.next_motion();
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
};
@@ -157,6 +194,8 @@ impl Game<'_> {
explosions,
attached,
guest: None,
owner: None,
left_owner: false,
attached_to: None,
angled: false,
item: stack.clone(),
@@ -172,7 +211,9 @@ impl Game<'_> {
hit.point[1] + f64::from(hit.face[1]) * 0.15,
hit.point[2] + f64::from(hit.face[2]) * 0.15,
);
let rocket = self.new_rocket(at, stack, false);
let mut rocket = self.new_rocket(at, stack, false);
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
if !self.creative {
self.consume_held();
@@ -182,10 +223,18 @@ impl Game<'_> {
}
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
pub(super) fn launch_angled_rocket(&mut self, at: Vec3, velocity: Vec3, stack: &ItemStack) {
pub(super) fn launch_angled_rocket(
&mut self,
at: Vec3,
velocity: Vec3,
stack: &ItemStack,
owner: Option<Who>,
) {
let mut rocket = self.new_rocket(at, stack, false);
rocket.velocity = velocity;
rocket.angled = true;
rocket.owner = owner;
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
self.rockets.push(rocket);
}
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
@@ -197,6 +246,7 @@ impl Game<'_> {
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true);
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
if !self.creative {
@@ -206,10 +256,33 @@ impl Game<'_> {
true
}
pub(super) fn tick_rockets(&mut self) {
let targets = if self.rockets.is_empty() {
Vec::new()
} else {
self.rocket_targets()
};
let mut i = 0;
while i < self.rockets.len() {
let mut r = self.rockets.remove(i);
r.previous = r.position;
// Projectile.tick: whether it has left its owner, before it moves.
let owner = self.rocket_owner(&r);
if !r.left_owner {
let h = 0.125;
let reach = Aabb::new(
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
)
.expand_towards(r.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
r.left_owner = owner.is_none_or(|o| {
!targets
.iter()
.any(|(who, b)| *who == o && b.intersects(reach))
});
}
let skip = owner.filter(|_| !r.left_owner);
let mut hit = None;
if r.attached
&& let Some(conn) = r.guest
{
@@ -235,7 +308,10 @@ impl Game<'_> {
r.position = p.position;
r.velocity = p.velocity;
}
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
} else {
// getHitResultOnMoveVector before the move.
hit = self.rocket_struck(&targets, &r, r.next_motion(), skip);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -246,6 +322,20 @@ impl Game<'_> {
r.life = r.lifetime;
}
}
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
// (ProjectileDeflection.REVERSE), anything else sets it off.
match hit {
Some(Who::Mob(id))
if self
.mobs
.iter()
.any(|m| m.id == id && m.species() == Species::Breeze) =>
{
r.velocity = r.velocity.scale(-0.5);
}
Some(_) => r.life = r.lifetime,
None => {}
}
let at = [r.position.x, r.position.y, r.position.z];
if r.life == 0 {
self.audio.play(
@@ -327,17 +417,28 @@ impl Game<'_> {
})
.collect()
};
for (id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(
amount,
Damage {
kind: DamageKind::Explosion,
attacker: None,
from: Some(at),
},
);
// damageSources().fireworks(this, getOwner()): a guest's rocket
// hurts as theirs, and the mobs it hit answer to them.
let owner = r.owner;
if let Some(conn) = r.guest.filter(|_| owner.is_some())
&& let Some(mut host) = self.lan.take()
{
let ran = self
.as_guest(&mut host, conn, |g| g.rocket_hurt_mobs(&hits, owner, at))
.is_some();
if ran {
for (id, _) in &hits {
if self.mobs.iter().any(|m| m.id == *id) {
host.mob_players.insert(*id, conn);
}
}
}
self.lan = Some(host);
if !ran {
self.rocket_hurt_mobs(&hits, owner, at);
}
} else {
self.rocket_hurt_mobs(&hits, owner, at);
}
}
let camera = self.eye_position().unwrap_or(at);
@@ -345,6 +446,87 @@ impl Game<'_> {
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
for &(id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(
amount,
Damage {
kind: DamageKind::Explosion,
attacker,
from: Some(at),
},
);
}
}
}
/// The owner as `rocket_targets` names it: a guest by their entity id.
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
let owner = r.owner?;
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
return Some(owner);
};
let host = self.lan.as_ref()?;
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
Some(Who::Mob(g.entity_id as u32))
}
/// What a rocket can hit (`Projectile.canHitEntity`): living mobs, the
/// dragon's parts and crystals, the player and the guests.
fn rocket_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
&& !self.spectator()
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
targets.extend(self.lan_projectile_targets());
targets
}
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
/// target on the move, short of the block it clips, each box grown by
/// `computeMargin` (`tickCount` is the rocket's life plus one).
fn rocket_struck(
&self,
targets: &[(Who, Aabb)],
r: &Rocket,
motion: Vec3,
skip: Option<Who>,
) -> Option<Who> {
let from = r.position;
let mut to = from.add_vector(motion);
let w = self.mob_world_base(&[]);
if let Some((at, ..)) = (super::creatures::ProjWorld {
w: &w,
targets: Vec::new(),
})
.clip(from, to)
{
to = at;
}
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
targets
.iter()
.filter(|(who, _)| Some(*who) != skip)
.filter_map(|(who, b)| {
let at = b
.inflate(Vec3::new(margin, margin, margin))
.clip(from, to)?;
Some((*who, from.distance_squared(at)))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(who, _)| who)
}
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
let camera = self.eye_position();
@@ -228,6 +228,11 @@ impl Game<'_> {
});
}
}
"minecraft:firework_rocket" => {
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
self.rockets.push(super::fireworks::Rocket::from_nbt(r));
}
}
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
"minecraft:tnt" => {
if let Some(position) = entity_nbt::position(&tag) {