From eed57ce5fca29df8826b28ecd761167238d3483a Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Wed, 8 Apr 2026 12:43:43 +0200 Subject: [PATCH] feat: Eye of Ender pointing to Stronghold --- pumpkin-world/src/generation/generator/mod.rs | 2 + .../generation/generator/structure_finder.rs | 170 ++++++++++++++ .../src/generation/structure/placement.rs | 7 + pumpkin/src/entity/projectile/eye_of_ender.rs | 222 ++++++++++++++++++ pumpkin/src/entity/projectile/mod.rs | 1 + pumpkin/src/item/items/ender_eye.rs | 114 +++++++-- 6 files changed, 492 insertions(+), 24 deletions(-) create mode 100644 pumpkin-world/src/generation/generator/structure_finder.rs create mode 100644 pumpkin/src/entity/projectile/eye_of_ender.rs diff --git a/pumpkin-world/src/generation/generator/mod.rs b/pumpkin-world/src/generation/generator/mod.rs index f8dc7a621..c5afffcae 100644 --- a/pumpkin-world/src/generation/generator/mod.rs +++ b/pumpkin-world/src/generation/generator/mod.rs @@ -9,6 +9,8 @@ use super::noise::router::proto_noise_router::ProtoNoiseRouters; use crate::generation::proto_chunk::TerrainCache; use crate::generation::{GlobalRandomConfig, Seed}; +pub mod structure_finder; + pub trait GeneratorInit { fn new(seed: Seed, dimension: Dimension) -> Self; } diff --git a/pumpkin-world/src/generation/generator/structure_finder.rs b/pumpkin-world/src/generation/generator/structure_finder.rs new file mode 100644 index 000000000..3aa198d7e --- /dev/null +++ b/pumpkin-world/src/generation/generator/structure_finder.rs @@ -0,0 +1,170 @@ +use pumpkin_data::structures::{ + ConcentricRingsStructurePlacement, RandomSpreadStructurePlacement, StructurePlacement, + StructurePlacementType, +}; +use pumpkin_util::math::{floor_div, position::BlockPos}; + +use crate::generation::structure::placement::{ + GlobalStructureCache, get_structure_chunk_in_region, +}; + +/// Block-level position of a found structure plus squared distance from the +/// search origin, used internally to track the running nearest candidate. +#[derive(Debug, Clone)] +pub struct FoundStructure { + pub pos: BlockPos, + pub distance_sq: f64, +} + +/// Finds the block position of the nearest structure whose placement is listed +/// in `placements`, within `max_search_radius` chunk-region rings. +/// +/// Mirrors the two-pass logic in vanilla's +/// `ChunkGenerator.findNearestMapStructure`: +/// +/// 1. **Concentric-rings** placements (strongholds) are resolved in one pass +/// from the pre-computed [`GlobalStructureCache`]. +/// 2. **Random-spread** placements are searched ring-by-ring outward, stopping +/// at the first radius that produces any result. +/// +/// The best candidate from both passes is returned. +pub fn find_nearest_structure( + origin: BlockPos, + placements: &[&StructurePlacement], + max_search_radius: i32, + world_seed: i64, + global_cache: &GlobalStructureCache, +) -> Option { + if placements.is_empty() { + return None; + } + + let mut nearest: Option = None; + + // ── Pass 1: Concentric-rings (strongholds) ────────────────────────────── + for p in placements { + if let StructurePlacementType::ConcentricRings(rings) = &p.placement_type + && let Some(found) = find_nearest_concentric(origin, rings, global_cache) + && nearest + .as_ref() + .is_none_or(|n| found.distance_sq < n.distance_sq) + { + nearest = Some(found); + } + } + + let random_spread: Vec<(&RandomSpreadStructurePlacement, u32)> = placements + .iter() + .filter_map(|p| { + if let StructurePlacementType::RandomSpread(r) = &p.placement_type { + Some((r, p.salt)) + } else { + None + } + }) + .collect(); + + if !random_spread.is_empty() { + let chunk_origin_x = origin.0.x >> 4; + let chunk_origin_z = origin.0.z >> 4; + + 'radius: for radius in 0..=max_search_radius { + for (placement, salt) in &random_spread { + if let Some(found) = find_nearest_random_spread_at_radius( + origin, + chunk_origin_x, + chunk_origin_z, + radius, + world_seed, + placement, + *salt, + ) { + if nearest + .as_ref() + .is_none_or(|n| found.distance_sq < n.distance_sq) + { + nearest = Some(found); + } + break 'radius; + } + } + } + } + + nearest.map(|f| f.pos) +} + +fn find_nearest_concentric( + origin: BlockPos, + // Kept for potential future bounds / distance validation. + _rings: &ConcentricRingsStructurePlacement, + global_cache: &GlobalStructureCache, +) -> Option { + let strongholds = global_cache.get_stronghold_chunks(); + if strongholds.is_empty() { + return None; + } + + let ox = origin.0.x as f64; + let oz = origin.0.z as f64; + + strongholds + .iter() + .map(|(cx, cz)| { + // Centre of the chunk in block coords. + let bx = (cx << 4) + 8; + let bz = (cz << 4) + 8; + let dx = bx as f64 - ox; + let dz = bz as f64 - oz; + FoundStructure { + pos: BlockPos::new(bx, 0, bz), + distance_sq: dx * dx + dz * dz, + } + }) + .min_by(|a, b| a.distance_sq.partial_cmp(&b.distance_sq).unwrap()) +} + +fn find_nearest_random_spread_at_radius( + origin: BlockPos, + chunk_origin_x: i32, + chunk_origin_z: i32, + radius: i32, + world_seed: i64, + placement: &RandomSpreadStructurePlacement, + salt: u32, +) -> Option { + let spacing = placement.spacing; + let ox = origin.0.x as f64; + let oz = origin.0.z as f64; + + let mut best: Option = None; + + for rx_off in -radius..=radius { + for rz_off in -radius..=radius { + if rx_off.abs() != radius && rz_off.abs() != radius { + continue; + } + + let rx = floor_div(chunk_origin_x, spacing) + rx_off; + let rz = floor_div(chunk_origin_z, spacing) + rz_off; + + let (struct_cx, struct_cz) = + get_structure_chunk_in_region(placement, world_seed, rx, rz, salt); + + let bx = (struct_cx << 4) + 8; + let bz = (struct_cz << 4) + 8; + let dx = bx as f64 - ox; + let dz = bz as f64 - oz; + let dist_sq = dx * dx + dz * dz; + + if best.as_ref().is_none_or(|b| dist_sq < b.distance_sq) { + best = Some(FoundStructure { + pos: BlockPos::new(bx, 0, bz), + distance_sq: dist_sq, + }); + } + } + } + + best +} diff --git a/pumpkin-world/src/generation/structure/placement.rs b/pumpkin-world/src/generation/structure/placement.rs index 05187dd25..f53f17dbf 100644 --- a/pumpkin-world/src/generation/structure/placement.rs +++ b/pumpkin-world/src/generation/structure/placement.rs @@ -32,6 +32,13 @@ impl GlobalStructureCache { } } + pub fn get_stronghold_chunks(&self) -> &[(i32, i32)] { + self.stronghold_chunks + .get() + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + /// Retrieves the list of chunk coordinates for Concentric Ring structures. /// If the cache is empty, it calculates the 128 ring positions mathematically. #[allow(clippy::cast_precision_loss)] diff --git a/pumpkin/src/entity/projectile/eye_of_ender.rs b/pumpkin/src/entity/projectile/eye_of_ender.rs new file mode 100644 index 000000000..f688cb572 --- /dev/null +++ b/pumpkin/src/entity/projectile/eye_of_ender.rs @@ -0,0 +1,222 @@ +use crate::entity::item::ItemEntity; +use crate::entity::living::LivingEntity; +use crate::entity::player::Player; +use crate::{entity::EntityBaseFuture, server::Server}; +use core::f64; +use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::sound::Sound; +use pumpkin_data::world::WorldEvent; +use pumpkin_data::{damage::DamageType, meta_data_type::MetaDataType, tracked_data::TrackedData}; +use pumpkin_protocol::{ + codec::item_stack_seralizer::ItemStackSerializer, java::client::play::Metadata, +}; +use pumpkin_util::math::vector3::Vector3; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU32, Ordering}, +}; +use tokio::sync::Mutex; + +use super::{Entity, EntityBase, NBTStorage}; + +/// Maximum horizontal distance the eye will travel before signalling at an elevated height. +const TOO_FAR_DISTANCE: f64 = 12.0; + +/// Height offset used when the target is beyond [`TOO_FAR_DISTANCE`]. +const TOO_FAR_SIGNAL_HEIGHT: f64 = 8.0; + +/// The eye despawns after this many ticks. +const MAX_LIFE: u32 = 80; + +/// Chance (1-in-5) the eye drops its item instead of playing the break effect. +const SURVIVE_CHANCE: u32 = 5; + +pub struct EyeOfEnder { + entity: Entity, + item_stack: Mutex, + target: Mutex>>, + life: AtomicU32, + survive_after_death: AtomicBool, +} + +impl EyeOfEnder { + pub fn new(entity: Entity) -> Self { + Self { + entity, + item_stack: Mutex::new(ItemStack::new(1, &Item::ENDER_EYE)), + target: Mutex::new(None), + life: AtomicU32::new(0), + survive_after_death: AtomicBool::new(false), + } + } + + /// Aim the eye at `target`, clamping to [`TOO_FAR_DISTANCE`] if necessary, + /// and randomly decide whether it should drop its item on expiry. + pub async fn signal_to(&self, target: Vector3) { + let pos = self.entity.pos.load(); + let delta = target.sub(&pos); + let horizontal_dist = delta.x.hypot(delta.z); + + let clamped_target = if horizontal_dist > TOO_FAR_DISTANCE { + Vector3::new( + pos.x + delta.x / horizontal_dist * TOO_FAR_DISTANCE, + pos.y + TOO_FAR_SIGNAL_HEIGHT, + pos.z + delta.z / horizontal_dist * TOO_FAR_DISTANCE, + ) + } else { + target + }; + + *self.target.lock().await = Some(clamped_target); + self.life.store(0, Ordering::Relaxed); + + // 4-in-5 chance to survive (drop item); 1-in-5 plays the break effect. + let survive = !rand::random::().is_multiple_of(SURVIVE_CHANCE); + self.survive_after_death.store(survive, Ordering::Relaxed); + } + + /// Compute the next delta-movement so the eye smoothly homes toward `target`. + /// + /// Mirrors the vanilla `updateDeltaMovement` logic. + fn compute_new_velocity( + old_velo: Vector3, + position: Vector3, + target: Vector3, + ) -> Vector3 { + let horizontal_delta = Vector3::new(target.x - position.x, 0.0, target.z - position.z); + let horizontal_len = horizontal_delta.x.hypot(horizontal_delta.z); + + let old_h_speed = old_velo.x.hypot(old_velo.z); + let mut wanted_speed = lerp(0.0025, old_h_speed, horizontal_len); + let mut move_y = old_velo.y; + + if horizontal_len < 1.0 { + wanted_speed *= 0.8; + move_y *= 0.8; + } + + let wanted_y_dir = if position.y - old_velo.y < target.y { + 1.0f64 + } else { + -1.0f64 + }; + + let scale = wanted_speed / horizontal_len; + Vector3::new( + horizontal_delta.x * scale, + move_y + (wanted_y_dir - move_y) * 0.015, + horizontal_delta.z * scale, + ) + } +} + +/// Linear interpolation helper (`start + t * (end - start)`). +#[inline] +fn lerp(t: f64, start: f64, end: f64) -> f64 { + start + t * (end - start) +} + +impl NBTStorage for EyeOfEnder {} + +impl EntityBase for EyeOfEnder { + fn tick<'a>( + &'a self, + caller: Arc, + server: &'a Server, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + let entity = &self.entity; + entity.tick(caller, server).await; + + // Advance position by current velocity. + let velocity = entity.velocity.load(); + let new_pos = entity.pos.load().add(&velocity); + + // Server-side: steer toward target if one is set. + { + let target_guard = self.target.lock().await; + if let Some(target) = *target_guard { + let new_velo = Self::compute_new_velocity(velocity, new_pos, target); + entity.velocity.store(new_velo); + } + } + + entity.set_pos(new_pos); + entity.send_pos_rot().await; + + // Tick lifetime and handle expiry. + let life = self.life.fetch_add(1, Ordering::Relaxed) + 1; + if life > MAX_LIFE { + entity.play_sound(Sound::EntityEnderEyeDeath).await; + entity.remove().await; + + if self.survive_after_death.load(Ordering::Relaxed) { + // Drop the item at the current position. + let item_stack = self.item_stack.lock().await.clone(); + let world = entity.world.load(); + let entity = Entity::new(world.clone(), new_pos, &EntityType::ITEM); + let item_entity = Arc::new(ItemEntity::new(entity, item_stack).await); + world.spawn_entity(item_entity).await; + } else { + entity + .world + .load() + .sync_world_event( + WorldEvent::ParticlesEyeOfEnderDeath, + new_pos.to_block_pos(), + 0, + ) + .await; + } + } + }) + } + + fn init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async { + self.entity + .send_meta_data(&[Metadata::new( + TrackedData::ITEM, + MetaDataType::ITEM_STACK, + &ItemStackSerializer::from(self.item_stack.lock().await.clone()), + )]) + .await; + }) + } + + fn damage_with_context<'a>( + &'a self, + _caller: &'a dyn EntityBase, + _amount: f32, + _damage_type: DamageType, + _position: Option>, + _source: Option<&'a dyn EntityBase>, + _cause: Option<&'a dyn EntityBase>, + ) -> EntityBaseFuture<'a, bool> { + // Eye of Ender is not attackable. + Box::pin(async { false }) + } + + fn on_player_collision<'a>(&'a self, _player: &'a Arc) -> EntityBaseFuture<'a, ()> { + // Eye of Ender cannot be picked up. + Box::pin(async {}) + } + + fn get_entity(&self) -> &Entity { + &self.entity + } + + fn get_living_entity(&self) -> Option<&LivingEntity> { + None + } + + fn get_item_entity(self: Arc) -> Option> { + None + } + + fn as_nbt_storage(&self) -> &dyn NBTStorage { + self + } +} diff --git a/pumpkin/src/entity/projectile/mod.rs b/pumpkin/src/entity/projectile/mod.rs index d182757ab..462226959 100644 --- a/pumpkin/src/entity/projectile/mod.rs +++ b/pumpkin/src/entity/projectile/mod.rs @@ -11,6 +11,7 @@ use std::{ }; pub mod arrow; pub mod egg; +pub mod eye_of_ender; pub mod firework_rocket; pub mod lingering_potion; pub mod snowball; diff --git a/pumpkin/src/item/items/ender_eye.rs b/pumpkin/src/item/items/ender_eye.rs index 0b4a3ee43..9986f2e12 100644 --- a/pumpkin/src/item/items/ender_eye.rs +++ b/pumpkin/src/item/items/ender_eye.rs @@ -1,15 +1,25 @@ use std::pin::Pin; use std::sync::Arc; -use crate::{server::Server, world::portal::end::EndPortal}; +use crate::entity::Entity; +use crate::entity::projectile::eye_of_ender::EyeOfEnder; +use crate::item::{ItemBehaviour, ItemMetadata}; +use crate::server::Server; +use crate::world::World; +use crate::world::portal::end::EndPortal; +use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; use pumpkin_data::item_stack::ItemStack; -use pumpkin_data::{Block, BlockDirection, item::Item}; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::structures::StructureSet; +use pumpkin_data::world::WorldEvent; +use pumpkin_data::{Block, BlockDirection}; use pumpkin_util::math::position::BlockPos; use pumpkin_util::math::vector3::Vector3; +use pumpkin_world::generation::generator::structure_finder::find_nearest_structure; use pumpkin_world::world::BlockFlags; -use crate::item::{ItemBehaviour, ItemMetadata}; -use crate::{entity::player::Player, world::World}; +use crate::entity::player::Player; pub struct EnderEyeItem; @@ -22,7 +32,7 @@ impl ItemMetadata for EnderEyeItem { impl ItemBehaviour for EnderEyeItem { fn use_on_block<'a>( &'a self, - _item: &'a mut ItemStack, + item: &'a mut ItemStack, player: &'a Player, location: BlockPos, _face: BlockDirection, @@ -37,17 +47,17 @@ impl ItemBehaviour for EnderEyeItem { let world = player.world(); let state_id = world.get_block_state_id(&location).await; - let original_props = block.properties(state_id).unwrap().to_props(); - let props: Vec<(&str, &str)> = original_props + // Skip if the frame already holds an eye. + let props_raw = block.properties(state_id).unwrap().to_props(); + if props_raw.iter().any(|(k, v)| *k == "eye" && *v == "true") { + return; + } + + // Build new state with eye=true. + let props: Vec<(&str, &str)> = props_raw .iter() - .map(|(key, value)| { - if *key == "eye" { - (*key, "true") - } else { - (*key, *value) - } - }) + .map(|(k, v)| if *k == "eye" { (*k, "true") } else { (*k, *v) }) .collect(); let new_state_id = block.from_properties(&props).to_state_id(block); @@ -55,7 +65,15 @@ impl ItemBehaviour for EnderEyeItem { .set_block_state(&location, new_state_id, BlockFlags::empty()) .await; + world + .sync_world_event(WorldEvent::EndPortalFrameFill, location, 0) + .await; + + // Try to complete the portal. EndPortal::get_new_portal(&world, location).await; + + // Consume one item. + item.decrement(1); }) } @@ -66,25 +84,73 @@ impl ItemBehaviour for EnderEyeItem { ) -> Pin + Send + 'a>> { Box::pin(async move { let world = player.world(); - let (start_pos, end_pos) = self.get_start_and_end_pos(player); - let checker = async |pos: &BlockPos, world_inner: &Arc| { - let state_id = world_inner.get_block_state_id(pos).await; - state_id != Block::AIR.default_state.id - }; - let Some((block_pos, _direction)) = world.raycast(start_pos, end_pos, checker).await - else { + let (start_pos, end_pos) = self.get_start_and_end_pos(player); + let checker = async |pos: &BlockPos, w: &Arc| { + w.get_block_state_id(pos).await != Block::AIR.default_state.id + }; + if let Some((hit_pos, _)) = world.raycast(start_pos, end_pos, checker).await + && world.get_block(&hit_pos).await == &Block::END_PORTAL_FRAME + { + return; + } + + let origin = player.living_entity.entity.block_pos.load(); + let target_block_pos = find_stronghold(&world, origin); + + let Some(target) = target_block_pos else { return; }; - let block = world.get_block(&block_pos).await; + let spawn_pos = Vector3::new( + player.living_entity.entity.pos.load().x, + player.living_entity.entity.pos.load().y + + f64::from(EntityType::EYE_OF_ENDER.dimension[1]) * 0.5, + player.living_entity.entity.pos.load().z, + ); - if block == &Block::END_PORTAL_FRAME {} + let entity = Entity::new(world.clone(), spawn_pos, &EntityType::EYE_OF_ENDER); + let eye = Arc::new(EyeOfEnder::new(entity)); + + let target_vec = Vector3::new( + f64::from(target.0.x), + f64::from(target.0.y), + f64::from(target.0.z), + ); + eye.signal_to(target_vec).await; + + world.spawn_entity(eye).await; + + let pitch = 0.33f32 + rand::random::() * (0.5 - 0.33); + world + .play_sound_fine( + Sound::EntityEnderEyeLaunch, + SoundCategory::Neutral, + &spawn_pos, + 1.0, + pitch, + ) + .await; + + player.inventory.held_item().lock().await.decrement(1); }) - //TODO Throw the Ender Eye in the direction of the stronghold. } fn as_any(&self) -> &dyn std::any::Any { self } } + +fn find_stronghold(world: &Arc, origin: BlockPos) -> Option { + let level = &world.level; + let generator = &level.world_gen; + let seed = level.seed.0; + + find_nearest_structure( + origin, + &[&StructureSet::get("strongholds").unwrap().placement], + 100, // max search radius in chunks, matches vanilla default + seed as i64, + &generator.global_structure_cache, + ) +}