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, target_delta_y: AtomicCell, target_delta_z: AtomicCell, 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, 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>) { 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 = 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>, _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> = nearby_entities .into_iter() .chain( nearby_players .into_iter() .map(|p| p as Arc), ) .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)); } } } } }