Pumpkin/crates/pumpkin/src/entity/projectile/shulker_bullet.rs
Alexander Medvedev 37437ee0a0 fix: many bugs
2026-08-27 21:23:24 +02:00

575 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use crossbeam::atomic::AtomicCell;
use pumpkin_data::damage::DamageType;
use pumpkin_data::effect::StatusEffect;
use pumpkin_data::entity::EntityType;
use pumpkin_data::particle::Particle;
use pumpkin_data::potion::Effect;
use pumpkin_data::sound::{Sound, SoundCategory};
use pumpkin_protocol::java::client::play::{CEntityPositionSync, CEntityVelocity};
use pumpkin_util::math::position::BlockPos;
use pumpkin_util::math::vector3::Vector3;
use rand::RngExt;
use uuid::Uuid;
use crate::entity::mob::shulker::Axis;
use crate::entity::{Entity, EntityBase};
use crate::server::Server;
// Direction ordinal constants
const DIR_DOWN: u8 = 0;
const DIR_UP: u8 = 1;
const DIR_NORTH: u8 = 2;
const DIR_SOUTH: u8 = 3;
const DIR_WEST: u8 = 4;
const DIR_EAST: u8 = 5;
const DIR_NONE: u8 = 255;
const SPEED: f64 = 0.15;
pub struct ShulkerBulletEntity {
pub entity: Entity,
pub owner_id: i32,
/// Entity id of the final target; -1 = no target, 0+ = valid target
target_id: AtomicI32,
/// Current movement direction (direction ordinal, or `DIR_NONE`)
current_dir: AtomicU8,
/// How many ticks remain on the current axis segment
flight_steps: AtomicI32,
/// Steering velocity components (updated each tick)
target_delta_x: AtomicCell<f64>,
target_delta_y: AtomicCell<f64>,
target_delta_z: AtomicCell<f64>,
has_hit: AtomicBool,
/// Tick counter; bullet is discarded after ~150 ticks if it never reaches its target
age: AtomicI32,
}
use std::sync::atomic::AtomicU8;
impl ShulkerBulletEntity {
pub fn new(
owner: &Entity,
target_id: i32,
target_pos: Vector3<f64>,
invalid_axis: Axis,
) -> Self {
let world = owner.world.load();
// Position at the center of the shulker's bounding box
let bb = owner.bounding_box.load();
let origin = Vector3::new(
f64::midpoint(bb.min.x, bb.max.x),
f64::midpoint(bb.min.y, bb.max.y),
f64::midpoint(bb.min.z, bb.max.z),
);
let entity = Entity::from_uuid(
Uuid::new_v4(),
(*world).clone(),
origin,
&EntityType::SHULKER_BULLET,
);
let bullet = Self {
entity,
owner_id: owner.entity_id,
target_id: AtomicI32::new(target_id),
current_dir: AtomicU8::new(DIR_UP),
flight_steps: AtomicI32::new(0),
target_delta_x: AtomicCell::new(0.0),
target_delta_y: AtomicCell::new(0.0),
target_delta_z: AtomicCell::new(0.0),
has_hit: AtomicBool::new(false),
age: AtomicI32::new(0),
};
// Prime target_delta and flight_steps so movement begins on tick 1.
let avoid_axis = invalid_axis as u8;
bullet.select_next_dir(avoid_axis, Some(target_pos));
bullet
}
#[allow(clippy::similar_names)]
fn select_next_dir(&self, avoid_axis: u8, target_pos_opt: Option<Vector3<f64>>) {
const Y_OFFSET: f64 = 0.5;
let pos = self.entity.pos.load();
let cur_bp = self.entity.block_pos.load();
let world = self.entity.world.load();
// Compute the target block position
let tbp = target_pos_opt.map_or_else(
|| BlockPos::new(cur_bp.0.x, cur_bp.0.y - 1, cur_bp.0.z), // Default: below
|tp| {
BlockPos::new(
// Target provided
tp.x.floor() as i32,
(tp.y + Y_OFFSET).floor() as i32,
tp.z.floor() as i32,
)
},
);
// ... and the "steering" target point.
let target_x = f64::from(tbp.0.x) + 0.5;
let target_y = f64::from(tbp.0.y) + Y_OFFSET;
let target_z = f64::from(tbp.0.z) + 0.5;
// If target block center is more than 2 blocks from bullet -> pick axis step.
let tbp_cx = f64::from(tbp.0.x) + 0.5;
let tbp_cy = f64::from(tbp.0.y) + 0.5;
let tbp_cz = f64::from(tbp.0.z) + 0.5;
let center_dist_sq =
(tbp_cx - pos.x).powi(2) + (tbp_cy - pos.y).powi(2) + (tbp_cz - pos.z).powi(2);
let target_is_far = center_dist_sq > 4.0;
let (chosen_tx, chosen_ty, chosen_tz, selected_dir) = if target_is_far {
// Pick an axis-aligned direction toward the target
let mut options: Vec<u8> = Vec::new();
if avoid_axis != 0 {
if cur_bp.0.x < tbp.0.x && is_empty_block(&world, &cur_bp, DIR_EAST) {
options.push(DIR_EAST);
} else if cur_bp.0.x > tbp.0.x && is_empty_block(&world, &cur_bp, DIR_WEST) {
options.push(DIR_WEST);
}
}
if avoid_axis != 1 {
if cur_bp.0.y < tbp.0.y && is_empty_block(&world, &cur_bp, DIR_UP) {
options.push(DIR_UP);
} else if cur_bp.0.y > tbp.0.y && is_empty_block(&world, &cur_bp, DIR_DOWN) {
options.push(DIR_DOWN);
}
}
if avoid_axis != 2 {
if cur_bp.0.z < tbp.0.z && is_empty_block(&world, &cur_bp, DIR_SOUTH) {
options.push(DIR_SOUTH);
} else if cur_bp.0.z > tbp.0.z && is_empty_block(&world, &cur_bp, DIR_NORTH) {
options.push(DIR_NORTH);
}
}
// If options empty, pick a random direction (up to 5 tries for an
// unobstructed one); ignore avoidAxis in this fallback.
let sel = if options.is_empty() {
let mut r = Self::random_dir();
for _ in 0..4 {
if is_empty_block(&world, &cur_bp, r) {
break;
}
r = Self::random_dir();
}
r
} else {
options[rand::rng().random_range(0..options.len())]
};
(
pos.x + f64::from(dir_step_x_s(sel)),
pos.y + f64::from(dir_step_y_s(sel)),
pos.z + f64::from(dir_step_z_s(sel)),
sel,
)
} else {
// If close, set selection=null and aim at the actual target position directly.
(target_x, target_y, target_z, DIR_NONE)
};
// Compute normalized steering delta scaled by SPEED.
let xa = chosen_tx - pos.x;
let ya = chosen_ty - pos.y;
let za = chosen_tz - pos.z;
let dist = (xa * xa + ya * ya + za * za).sqrt();
if dist == 0.0 {
self.target_delta_x.store(0.0);
self.target_delta_y.store(0.0);
self.target_delta_z.store(0.0);
} else {
self.target_delta_x.store(xa / dist * SPEED);
self.target_delta_y.store(ya / dist * SPEED);
self.target_delta_z.store(za / dist * SPEED);
}
self.current_dir.store(selected_dir, Ordering::Relaxed);
let steps = 10 + rand::rng().random_range(0..5) * 10;
self.flight_steps.store(steps, Ordering::Relaxed);
}
fn random_dir() -> u8 {
rand::rng().random_range(0u8..6)
}
}
// Free-standing step helpers (used inside async closures that can't capture self.*)
const fn dir_step_x_s(dir: u8) -> i32 {
match dir {
DIR_EAST => 1,
DIR_WEST => -1,
_ => 0,
}
}
const fn dir_step_y_s(dir: u8) -> i32 {
match dir {
DIR_UP => 1,
DIR_DOWN => -1,
_ => 0,
}
}
const fn dir_step_z_s(dir: u8) -> i32 {
match dir {
DIR_SOUTH => 1,
DIR_NORTH => -1,
_ => 0,
}
}
/// Returns the axis ordinal (0=X, 1=Y, 2=Z) of a direction.
/// Returns 255 for `DIR_NONE` or any unknown value.
const fn dir_axis(dir: u8) -> u8 {
match dir {
DIR_EAST | DIR_WEST => 0,
DIR_UP | DIR_DOWN => 1,
DIR_NORTH | DIR_SOUTH => 2,
_ => 255,
}
}
/// `true` if the block one step in `dir` from `from` is air (the bullet
/// can pass through it). Unloaded chunks are treated as empty.
fn is_empty_block(world: &crate::world::World, from: &BlockPos, dir: u8) -> bool {
let nb = BlockPos::new(
from.0.x + dir_step_x_s(dir),
from.0.y + dir_step_y_s(dir),
from.0.z + dir_step_z_s(dir),
);
world
.get_block_state_if_loaded(&nb)
.is_none_or(pumpkin_data::BlockState::is_air)
}
impl ShulkerBulletEntity {
/// Create a `ShulkerBulletEntity` from a pre-existing Entity (e.g. loaded from
/// disk or spawned via the /summon command with no context).
pub const fn orphan(entity: Entity) -> Self {
Self {
entity,
owner_id: 0,
target_id: AtomicI32::new(-1),
current_dir: AtomicU8::new(DIR_NONE),
flight_steps: AtomicI32::new(0),
target_delta_x: AtomicCell::new(0.0),
target_delta_y: AtomicCell::new(0.0),
target_delta_z: AtomicCell::new(0.0),
has_hit: AtomicBool::new(false),
age: AtomicI32::new(0),
}
}
}
impl EntityBase for ShulkerBulletEntity {
fn get_entity(&self) -> &Entity {
&self.entity
}
fn get_living_entity(&self) -> Option<&crate::entity::living::LivingEntity> {
None
}
fn cast_any(&self) -> &dyn std::any::Any {
self
}
/// Any hit destroys the bullet (melee, arrow, etc.).
fn damage_with_context(
&self,
_caller: &dyn EntityBase,
_amount: f32,
_damage_type: DamageType,
_position: Option<Vector3<f64>>,
_source: Option<&dyn EntityBase>,
_cause: Option<&dyn EntityBase>,
) -> bool {
// Guard against double-hit
if self.has_hit.swap(true, Ordering::SeqCst) {
return false;
}
let entity = &self.entity;
let world = entity.world.load();
let pos = entity.pos.load();
world.play_sound_fine(
Sound::EntityShulkerBulletHit,
SoundCategory::Hostile,
&pos,
1.0,
1.0,
);
world.spawn_particle(
pos,
Vector3::new(0.2, 0.2, 0.2),
0.0,
2,
Particle::Explosion,
);
entity.remove();
true
}
#[allow(clippy::too_many_lines)]
fn tick(&self, _caller: &dyn EntityBase, _server: &Server) {
if self.has_hit.load(Ordering::Relaxed) {
return;
}
// Discard bullet after 150 ticks if it never hits anything
let age = self.age.fetch_add(1, Ordering::Relaxed) + 1;
if age > 150 {
if !self.has_hit.swap(true, Ordering::SeqCst) {
let entity = &self.entity;
let world = entity.world.load();
let pos = entity.pos.load();
world.play_sound_fine(
Sound::EntityShulkerBulletHit,
SoundCategory::Hostile,
&pos,
1.0,
1.0,
);
world.spawn_particle(
pos,
Vector3::new(0.2, 0.2, 0.2),
0.0,
2,
Particle::Explosion,
);
entity.remove();
}
return;
}
let entity = &self.entity;
let world = entity.world.load();
let target_id = self.target_id.load(Ordering::Relaxed);
let target_opt = if target_id >= 0 {
world.get_entity_by_id(target_id)
} else {
None
};
// Apply gravity only if target is null, dead, or a spectator.
let target_alive = target_opt
.as_ref()
.is_some_and(|t| t.get_entity().is_alive());
if !target_alive && target_id >= 0 {
// Target ID is set but entity was not found (dead/left the world).
// Only permanently clear the target when we're sure it's gone.
if let Some(t) = &target_opt {
if !t.get_entity().is_alive() {
self.target_id.store(-1, Ordering::Relaxed);
}
} else {
// Not found -> clear target
self.target_id.store(-1, Ordering::Relaxed);
}
}
if target_alive {
// Accelerate target deltas x 1.025, clamped
let mut tdx = (self.target_delta_x.load() * 1.025).clamp(-1.0, 1.0);
let mut tdy = (self.target_delta_y.load() * 1.025).clamp(-1.0, 1.0);
let mut tdz = (self.target_delta_z.load() * 1.025).clamp(-1.0, 1.0);
self.target_delta_x.store(tdx);
self.target_delta_y.store(tdy);
self.target_delta_z.store(tdz);
// Clamp initialised sentinel values
if tdx.abs() < 1e-10 && tdy.abs() < 1e-10 && tdz.abs() < 1e-10 {
tdx = 0.0;
tdy = 0.0;
tdz = 0.0;
}
// Lerp actual velocity toward steering delta
let mut vel = entity.velocity.load();
vel.x += (tdx - vel.x) * 0.2;
vel.y += (tdy - vel.y) * 0.2;
vel.z += (tdz - vel.z) * 0.2;
entity.velocity.store(vel);
} else {
// No live target – apply gravity and drift
let mut vel = entity.velocity.load();
vel.y -= 0.04;
entity.velocity.store(vel);
}
let vel = entity.velocity.load();
let old_pos = entity.pos.load();
let new_pos = old_pos.add(&vel);
entity.set_pos(new_pos);
// Broadcast position and velocity
let chunk_pos = entity.chunk_pos.load();
world.broadcast_to_chunk(
chunk_pos,
&CEntityPositionSync::new(
entity.entity_id.into(),
new_pos,
vel,
entity.yaw.load(),
entity.pitch.load(),
false,
),
);
world.broadcast_to_chunk(
chunk_pos,
&CEntityVelocity::new(entity.entity_id.into(), vel),
);
// Check for block collisions
let new_bp = entity.block_pos.load();
let state = world.get_block_state(&new_bp);
if !state.is_air() && state.is_solid() {
if !self.has_hit.swap(true, Ordering::SeqCst) {
let pos = entity.pos.load();
world.play_sound_fine(
Sound::EntityShulkerBulletHit,
SoundCategory::Hostile,
&pos,
1.0,
1.0,
);
world.spawn_particle(
pos,
Vector3::new(0.2, 0.2, 0.2),
0.0,
2,
Particle::Explosion,
);
entity.remove();
}
return;
}
// Check for entity collisions
let bullet_bb = entity.bounding_box.load().expand(0.1, 0.1, 0.1);
let nearby_entities = world.get_entities_at_box(&bullet_bb);
let nearby_players = world.get_players_at_box(&bullet_bb);
let nearby: Vec<Arc<dyn crate::entity::EntityBase>> = nearby_entities
.into_iter()
.chain(
nearby_players
.into_iter()
.map(|p| p as Arc<dyn crate::entity::EntityBase>),
)
.collect();
for hit_entity in nearby {
let he = hit_entity.get_entity();
// Skip self
if he.entity_id == entity.entity_id {
continue;
}
// Never hit the owner shulker
if he.entity_id == self.owner_id {
continue;
}
// Must be alive
if !he.is_alive() {
continue;
}
// Must be a living entity
let Some(living) = hit_entity.get_living_entity() else {
continue;
};
if !living.entity.is_alive() {
continue;
}
if self.has_hit.swap(true, Ordering::SeqCst) {
break;
}
// Deal 4 (MOB_PROJECTILE) damage
let owner_arc = world.get_entity_by_id(self.owner_id);
let damaged = hit_entity.damage_with_context(
hit_entity.as_ref(),
4.0,
DamageType::MOB_PROJECTILE,
None,
owner_arc.as_deref(),
None,
);
if damaged && let Some(living) = hit_entity.get_living_entity() {
// Apply levitation for 200 ticks
living.add_effect(Effect {
effect_type: &StatusEffect::LEVITATION,
duration: 200,
amplifier: 0,
ambient: false,
show_particles: true,
show_icon: true,
blend: false,
});
}
let pos = entity.pos.load();
world.spawn_particle(
pos,
Vector3::new(0.2, 0.2, 0.2),
0.0,
2,
Particle::Explosion,
);
entity.remove();
break;
}
if !target_alive || self.has_hit.load(Ordering::Relaxed) {
return;
}
let target_pos = target_opt.as_ref().map(|t| t.get_entity().pos.load());
let raw_dir = self.current_dir.load(Ordering::Relaxed);
let avoid_axis = dir_axis(raw_dir);
// Decrement flight-step counter; re-select direction when it hits 0
let steps = self.flight_steps.fetch_sub(1, Ordering::Relaxed) - 1;
if steps <= 0 {
self.select_next_dir(avoid_axis, target_pos);
}
// Check the block immediately ahead.
// If it is solid we must re-select; if we've aligned axes with the target we also re-select.
let dir = self.current_dir.load(Ordering::Relaxed);
if dir != DIR_NONE {
let cur_bp = entity.block_pos.load();
if !is_empty_block(&world, &cur_bp, dir) {
// Solid obstacle -> navigate around it
self.select_next_dir(dir_axis(dir), target_pos);
} else if let Some(tp) = target_pos {
let axis = dir_axis(dir);
let tbp = BlockPos::new(
tp.x.floor() as i32,
tp.y.floor() as i32,
tp.z.floor() as i32,
);
let cur_bp2 = entity.block_pos.load();
let reached = match axis {
0 => cur_bp2.0.x == tbp.0.x,
1 => cur_bp2.0.y == tbp.0.y,
_ => cur_bp2.0.z == tbp.0.z,
};
if reached {
// Aligned on this axis -> switch to next best axis
self.select_next_dir(axis, Some(tp));
}
}
}
}
}