diff --git a/steel-core/src/behavior/block.rs b/steel-core/src/behavior/block.rs index 07aedeb57935..026eb94038f7 100644 --- a/steel-core/src/behavior/block.rs +++ b/steel-core/src/behavior/block.rs @@ -548,6 +548,11 @@ pub trait BlockBehavior: Send + Sync { true } + /// Returns whether this block can be occupied by a forced respawn position + fn is_possible_to_respawn_in_this(&self, state: BlockStateId) -> bool { + !state.is_solid() && !state.has_fluid() + } + /// Returns the block state to use when placing this block. fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option; diff --git a/steel-core/src/behavior/blocks/building/bed_block.rs b/steel-core/src/behavior/blocks/building/bed_block.rs index 7436df48bbdd..67868c9b610d 100644 --- a/steel-core/src/behavior/blocks/building/bed_block.rs +++ b/steel-core/src/behavior/blocks/building/bed_block.rs @@ -2,29 +2,43 @@ use std::sync::Arc; use glam::DVec3; use steel_macros::block_behavior; -use steel_registry::blocks::BlockRef; -use steel_utils::{BlockPos, BlockStateId}; +use steel_registry::blocks::{ + BlockRef, block_state_ext::BlockStateExt, properties::BedPart, properties::BlockStateProperties, +}; +use steel_registry::vanilla_block_tags::BlockTag; +use steel_registry::vanilla_blocks; +use steel_utils::{BlockPos, BlockStateId, Direction, types::UpdateFlags}; +use text_components::TextComponent; +use text_components::translation::TranslatedMessage; use crate::{ behavior::{ - BlockBehavior, BlockPlaceContext, EntityFallDamage, EntityFallOnContext, - EntityLandingContext, + BlockBehavior, BlockHitResult, BlockPlaceContext, EntityFallDamage, EntityFallOnContext, + EntityLandingContext, InteractionResult, InventoryAccess, }, - world::World, + entity::{Entity, dismount_helper}, + player::Player, + world::{ScheduledTickAccess, World}, }; const BED_BOUNCE_SCALE: f64 = 0.660_000_026_226_043_7; -/// Behavior for beds. +/// Behavior for beds /// -/// TODO: Add two-block placement, bed block entities, sleep interaction, and -/// invalid-dimension explosion behavior with the rest of the bed system. +/// TODO: Mirror vanilla `BedBlock.useWithoutItem` invalid-dimension explosion +/// once Steel has a strict `World::explode` foundation: show the bed-rule error +/// message, remove both bed halves, and use bad-respawn-point explosion damage. +/// TODO: Mirror vanilla `BedBlock.kickVillagerOutOfBed` once villager sleeping +/// entities exist. #[block_behavior] pub struct BedBlock { block: BlockRef, } impl BedBlock { + const HEAD_PLACE_FLAGS: UpdateFlags = UpdateFlags::UPDATE_ALL; + const UPDATE_NEIGHBOR_SHAPES_LIMIT: i32 = 512; + /// Creates a bed block behavior. #[must_use] pub const fn new(block: BlockRef) -> Self { @@ -49,11 +63,206 @@ impl BedBlock { context.velocity.z, ) } + + fn head_state_and_pos( + &self, + world: &Arc, + state: BlockStateId, + pos: BlockPos, + ) -> Option<(BlockStateId, BlockPos)> { + if state.get_value(&BlockStateProperties::BED_PART) == BedPart::Head { + return Some((state, pos)); + } + + let head_pos = state + .get_value(&BlockStateProperties::HORIZONTAL_FACING) + .relative(pos); + let head_state = world.get_block_state(head_pos); + (head_state.get_block() == self.block).then_some((head_state, head_pos)) + } + + const fn neighbor_direction(part: &BedPart, facing: Direction) -> Direction { + match part { + BedPart::Foot => facing, + BedPart::Head => facing.opposite(), + } + } + + pub(crate) fn find_standup_position( + world: &Arc, + entity: &dyn Entity, + forward_dir: Direction, + block_pos: BlockPos, + ) -> Option { + Self::find_standup_position_with_yaw( + world, + entity, + forward_dir, + block_pos, + entity.rotation().0, + ) + } + + pub(crate) fn find_standup_position_with_yaw( + world: &Arc, + entity: &dyn Entity, + forward_dir: Direction, + block_pos: BlockPos, + yaw: f32, + ) -> Option { + let right = forward_dir.rotate_y_clockwise(); + let side = if right.is_facing_angle(yaw) { + right.opposite() + } else { + right + }; + + if world + .get_block_state(block_pos.below()) + .get_block() + .has_tag(&BlockTag::BEDS) + { + Self::find_bunk_bed_standup_position(world, entity, forward_dir, side, block_pos) + } else { + let offsets = Self::bed_standup_offsets(forward_dir, side); + + if let Some(safe_pos) = + Self::find_standup_position_at_offset(world, entity, block_pos, &offsets, true) + { + return Some(safe_pos); + } + + Self::find_standup_position_at_offset(world, entity, block_pos, &offsets, false) + } + } + + fn find_bunk_bed_standup_position( + world: &Arc, + entity: &dyn Entity, + forward_dir: Direction, + side_dir: Direction, + block_pos: BlockPos, + ) -> Option { + let offsets = Self::bed_surround_standup_offsets(forward_dir, side_dir); + + if let Some(pos) = + Self::find_standup_position_at_offset(world, entity, block_pos, &offsets, true) + { + return Some(pos); + } + + let below = block_pos.below(); + if let Some(pos) = + Self::find_standup_position_at_offset(world, entity, below, &offsets, true) + { + return Some(pos); + } + + let above_offsets = Self::bed_above_standup_offsets(forward_dir); + if let Some(pos) = + Self::find_standup_position_at_offset(world, entity, block_pos, &above_offsets, true) + { + return Some(pos); + } + + if let Some(pos) = + Self::find_standup_position_at_offset(world, entity, block_pos, &offsets, false) + { + return Some(pos); + } + + if let Some(pos) = + Self::find_standup_position_at_offset(world, entity, below, &offsets, false) + { + return Some(pos); + } + + Self::find_standup_position_at_offset(world, entity, block_pos, &above_offsets, false) + } + + fn find_standup_position_at_offset( + world: &Arc, + entity: &dyn Entity, + pos: BlockPos, + offsets: &[(i32, i32)], + check_dangerous: bool, + ) -> Option { + for &(off_x, off_z) in offsets { + let offset_pos = BlockPos::new(pos.x() + off_x, pos.y(), pos.z() + off_z); + if let Some(position) = dismount_helper::find_safe_dismount_location( + world, + entity, + offset_pos, + check_dangerous, + ) { + return Some(position); + } + } + + None + } + + #[must_use] + const fn bed_standup_offsets(forward: Direction, side: Direction) -> [(i32, i32); 12] { + let surround = Self::bed_surround_standup_offsets(forward, side); + let above = Self::bed_above_standup_offsets(forward); + + [ + surround[0], + surround[1], + surround[2], + surround[3], + surround[4], + surround[5], + surround[6], + surround[7], + surround[8], + surround[9], + above[0], + above[1], + ] + } + + #[must_use] + const fn bed_surround_standup_offsets(forward: Direction, side: Direction) -> [(i32, i32); 10] { + let (fx, fz) = forward.offset_xz(); + let (sx, sz) = side.offset_xz(); + + [ + (sx, sz), + (sx - fx, sz - fz), + (sx - fx * 2, sz - fz * 2), + (-fx * 2, -fz * 2), + (-sx - fx * 2, -sz - fz * 2), + (-sx - fx, -sz - fz), + (-sx, -sz), + (-sx + fx, -sz + fz), + (fx, fz), + (sx + fx, sz + fz), + ] + } + + #[must_use] + const fn bed_above_standup_offsets(forward: Direction) -> [(i32, i32); 2] { + let (fx, fz) = forward.offset_xz(); + [(0, 0), (-fx, -fz)] + } } impl BlockBehavior for BedBlock { - fn get_state_for_placement(&self, _context: &BlockPlaceContext<'_>) -> Option { - Some(self.block.default_state()) + fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option { + let facing = context.horizontal_direction; + let head_pos = facing.relative(context.place_pos); + let head_state = context.world.get_block_state(head_pos); + if !head_state.is_replaceable() || !context.world.is_in_valid_bounds(head_pos) { + return None; + } + + Some( + self.block + .default_state() + .set_value(&BlockStateProperties::HORIZONTAL_FACING, facing), + ) } fn fall_on( @@ -79,6 +288,128 @@ impl BlockBehavior for BedBlock { Self::velocity_after_fall(context) } + + fn player_will_destroy( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + player: &Player, + ) -> BlockStateId { + if !player.has_infinite_materials() + || state.get_value(&BlockStateProperties::BED_PART) != BedPart::Foot + { + return state; + } + + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + let head_pos = Self::neighbor_direction(&BedPart::Foot, facing).relative(pos); + let head_state = world.get_block_state(head_pos); + if head_state.get_block() != self.block + || head_state.get_value(&BlockStateProperties::BED_PART) != BedPart::Head + { + return state; + } + + world.set_block( + head_pos, + vanilla_blocks::AIR.default_state(), + UpdateFlags::UPDATE_ALL | UpdateFlags::UPDATE_SUPPRESS_DROPS, + ); + world.destroy_block_effect(head_pos, u32::from(head_state.0), Some(player.id())); + state + } + + fn update_shape( + &self, + state: BlockStateId, + _world: &dyn ScheduledTickAccess, + _pos: BlockPos, + direction: Direction, + _neighbor_pos: BlockPos, + neighbor_state: BlockStateId, + ) -> BlockStateId { + let part = state.get_value(&BlockStateProperties::BED_PART); + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + if direction != Self::neighbor_direction(&part, facing) { + return state; + } + + if neighbor_state.get_block() == self.block + && neighbor_state.get_value(&BlockStateProperties::BED_PART) != part + { + return state.set_value( + &BlockStateProperties::OCCUPIED, + neighbor_state.get_value(&BlockStateProperties::OCCUPIED), + ); + } + + vanilla_blocks::AIR.default_state() + } + + fn set_placed_by( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + _player: Option<&Player>, + _inv: &InventoryAccess, + ) { + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + let head_pos = facing.relative(pos); + let head_state = state.set_value(&BlockStateProperties::BED_PART, BedPart::Head); + + world.set_block(head_pos, head_state, Self::HEAD_PLACE_FLAGS); + world.update_neighbors_at(pos, &vanilla_blocks::AIR); + world.update_neighbor_shapes_at( + state, + pos, + Self::HEAD_PLACE_FLAGS, + Self::UPDATE_NEIGHBOR_SHAPES_LIMIT, + ); + } + + fn use_without_item( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + player: &Player, + _hit_result: &BlockHitResult, + _inv: &mut InventoryAccess, + ) -> InteractionResult { + let Some((head_state, head_pos)) = self.head_state_and_pos(world, state, pos) else { + return InteractionResult::Consume; + }; + + if world.dimension_type.bed_rule.explodes { + // TODO: Mirror vanilla BedBlock explosion exactly once + // `World::explode` exists: display the bed-rule error message, + // remove the head and foot halves, then create a bad-respawn-point + // block explosion at the bed center. + return InteractionResult::SuccessServer; + } + + if head_state.get_value(&BlockStateProperties::OCCUPIED) { + // TODO: Mirror vanilla `kickVillagerOutOfBed`: find a sleeping + // villager in this bed AABB and call `stopSleeping` once villager + // sleeping exists. + player.send_overlay_message(&TextComponent::translated(TranslatedMessage { + key: "block.minecraft.bed.occupied".into(), + fallback: None, + args: None, + })); + return InteractionResult::SuccessServer; + } + + if let Err(problem) = player.start_sleep_in_bed(head_pos) + && let Some(message) = problem.message() + { + player.send_overlay_message(message); + } + + InteractionResult::SuccessServer + } } #[cfg(test)] diff --git a/steel-core/src/behavior/blocks/building/mod.rs b/steel-core/src/behavior/blocks/building/mod.rs index 206fb499eb7d..cd57d6b2f2e2 100644 --- a/steel-core/src/behavior/blocks/building/mod.rs +++ b/steel-core/src/behavior/blocks/building/mod.rs @@ -13,6 +13,7 @@ mod lava_cauldron_block; mod magma_block; mod potent_sulfur_block; mod powder_snow_block; +mod respawn_anchor_block; mod rotated_pillar_block; mod scaffolding_block; mod slab_block; @@ -40,6 +41,7 @@ pub use lava_cauldron_block::LavaCauldronBlock; pub use magma_block::MagmaBlock; pub use potent_sulfur_block::PotentSulfurBlock; pub use powder_snow_block::PowderSnowBlock; +pub use respawn_anchor_block::RespawnAnchorBlock; pub use rotated_pillar_block::RotatedPillarBlock; pub use scaffolding_block::ScaffoldingBlock; pub use slab_block::{SlabBlock, WeatheringCopperSlabBlock}; diff --git a/steel-core/src/behavior/blocks/building/respawn_anchor_block.rs b/steel-core/src/behavior/blocks/building/respawn_anchor_block.rs new file mode 100644 index 000000000000..16e809e19117 --- /dev/null +++ b/steel-core/src/behavior/blocks/building/respawn_anchor_block.rs @@ -0,0 +1,319 @@ +use std::sync::Arc; + +use glam::DVec3; +use steel_macros::block_behavior; +use steel_protocol::packets::game::SoundSource; +use steel_registry::blocks::{ + BlockRef, block_state_ext::BlockStateExt, properties::BlockStateProperties, +}; +use steel_registry::item_stack::ItemStack; +use steel_registry::{sound_events, vanilla_blocks, vanilla_game_events, vanilla_items}; +use steel_utils::{ + BlockPos, BlockStateId, + types::{InteractionHand, UpdateFlags}, +}; + +use crate::entity::dismount_helper; +use crate::{ + behavior::{ + BlockBehavior, BlockHitResult, BlockPlaceContext, InteractionResult, InventoryAccess, + }, + entity::Entity, + level_data::RespawnData, + player::{Player, PlayerRespawnConfig}, + world::{World, game_event_context::GameEventContext}, +}; + +/// Vanilla respawn anchor +/// +/// TODO: Implement vanilla invalid-dimension explosion once Steel has a strict +/// `World::explode` foundation, including block removal, water-sensitive +/// explosion resistance, and bad-respawn-point explosion damage source. +#[block_behavior] +pub struct RespawnAnchorBlock { + block: BlockRef, +} + +impl RespawnAnchorBlock { + /// New respawn anchor behavior + #[must_use] + pub const fn new(block: BlockRef) -> Self { + Self { block } + } + + #[must_use] + pub(crate) fn can_use_for_respawn( + world: &World, + pos: BlockPos, + state: BlockStateId, + forced: bool, + ) -> bool { + state.get_block() == &vanilla_blocks::RESPAWN_ANCHOR + && (forced || state.get_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES) > 0) + && Self::can_set_spawn(world, pos) + } + + #[must_use] + pub(crate) const fn can_set_spawn(world: &World, _pos: BlockPos) -> bool { + world.dimension_type.respawn_anchor_works + } + + pub(crate) fn find_standup_position( + world: &Arc, + entity: &dyn Entity, + pos: BlockPos, + ) -> Option { + for (candidate, check_dangerous) in Self::standup_search_candidates(pos) { + if let Some(position) = dismount_helper::find_safe_dismount_location( + world, + entity, + candidate, + check_dangerous, + ) { + return Some(position); + } + } + + None + } + + pub(crate) fn consume_charge(world: &Arc, pos: BlockPos, state: BlockStateId) { + let charges = state.get_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES); + world.set_block( + pos, + state.set_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES, charges - 1), + UpdateFlags::UPDATE_ALL, + ); + } + + #[must_use] + pub(crate) const fn should_consume_charge_after_respawn( + forced: bool, + consume_spawn_block: bool, + found_standup_position: bool, + ) -> bool { + !forced && consume_spawn_block && found_standup_position + } + + fn standup_candidates(pos: BlockPos) -> impl Iterator { + [pos, pos.below(), pos.above()] + .into_iter() + .flat_map(Self::horizontal_standup_candidates) + .chain([pos.above()]) + } + + fn standup_search_candidates(pos: BlockPos) -> impl Iterator { + [true, false].into_iter().flat_map(move |check_dangerous| { + Self::standup_candidates(pos).map(move |candidate| (candidate, check_dangerous)) + }) + } + + const fn horizontal_standup_candidates(pos: BlockPos) -> [BlockPos; 8] { + [ + pos.north(), + pos.west(), + pos.south(), + pos.east(), + pos.north().west(), + pos.north().east(), + pos.south().west(), + pos.south().east(), + ] + } + + fn has_charge(state: BlockStateId) -> bool { + state.get_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES) > 0 + } + + fn can_be_charged(state: BlockStateId) -> bool { + state.get_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES) < 4 + } + + fn is_respawn_fuel(item_stack: &ItemStack) -> bool { + item_stack.is(&vanilla_items::ITEMS.glowstone) + } + + fn player_offhand_has_respawn_fuel(player: &Player) -> bool { + player + .inventory + .lock() + .get_item_in_hand(InteractionHand::OffHand) + .is(&vanilla_items::ITEMS.glowstone) + } + + fn charge(source: Option<&dyn Entity>, world: &Arc, pos: BlockPos, state: BlockStateId) { + let charges = state.get_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES); + let charged_state = + state.set_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES, charges + 1); + world.set_block(pos, charged_state, UpdateFlags::UPDATE_ALL); + world.game_event( + &vanilla_game_events::BLOCK_CHANGE, + pos, + &GameEventContext::new(source, Some(charged_state)), + ); + world.play_sound( + &sound_events::BLOCK_RESPAWN_ANCHOR_CHARGE, + SoundSource::Blocks, + pos, + 1.0, + 1.0, + None, + ); + } +} + +impl BlockBehavior for RespawnAnchorBlock { + fn get_state_for_placement(&self, _context: &BlockPlaceContext<'_>) -> Option { + Some(self.block.default_state()) + } + + fn use_item_on( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + player: &Player, + hand: InteractionHand, + _hit_result: &BlockHitResult, + inv: &mut InventoryAccess, + ) -> InteractionResult { + let is_respawn_fuel = inv.with_item(|item_stack| Self::is_respawn_fuel(item_stack)); + if is_respawn_fuel && Self::can_be_charged(state) { + Self::charge(Some(player), world, pos, state); + inv.with_item(|item_stack| item_stack.shrink(1)); + return InteractionResult::Success; + } + + if hand == InteractionHand::MainHand + && Self::can_be_charged(state) + && Self::player_offhand_has_respawn_fuel(player) + { + return InteractionResult::Pass; + } + + InteractionResult::TryEmptyHandInteraction + } + + fn use_without_item( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + player: &Player, + _hit_result: &BlockHitResult, + _inv: &mut InventoryAccess, + ) -> InteractionResult { + if !Self::has_charge(state) { + return InteractionResult::Pass; + } + + if !Self::can_set_spawn(world, pos) { + // TODO: Mirror vanilla RespawnAnchorBlock.explode exactly once + // `World::explode` exists: remove the anchor, use water-sensitive + // explosion resistance, and use bad-respawn-point explosion damage. + return InteractionResult::SuccessServer; + } + + let respawn_config = PlayerRespawnConfig { + respawn_data: RespawnData::of(world.key.clone(), pos, 0.0, 0.0), + forced: false, + }; + let should_update_spawn = player.respawn_config().as_ref().is_none_or(|current| { + current.respawn_data.global_pos != respawn_config.respawn_data.global_pos + }); + + if !should_update_spawn { + return InteractionResult::Consume; + } + + player.set_respawn_position(Some(respawn_config), true); + world.play_sound( + &sound_events::BLOCK_RESPAWN_ANCHOR_SET_SPAWN, + SoundSource::Blocks, + pos, + 1.0, + 1.0, + None, + ); + InteractionResult::SuccessServer + } +} + +#[cfg(test)] +mod tests { + use super::RespawnAnchorBlock; + use steel_registry::blocks::{ + block_state_ext::BlockStateExt, properties::BlockStateProperties, + }; + use steel_registry::{test_support::init_test_registry, vanilla_blocks}; + use steel_utils::BlockPos; + + #[test] + fn standup_candidates_match_vanilla_order() { + let origin = BlockPos::new(10, 64, -20); + let candidates = RespawnAnchorBlock::standup_candidates(origin).collect::>(); + + assert_eq!( + candidates, + vec![ + origin.north(), + origin.west(), + origin.south(), + origin.east(), + origin.north().west(), + origin.north().east(), + origin.south().west(), + origin.south().east(), + origin.below().north(), + origin.below().west(), + origin.below().south(), + origin.below().east(), + origin.below().north().west(), + origin.below().north().east(), + origin.below().south().west(), + origin.below().south().east(), + origin.above().north(), + origin.above().west(), + origin.above().south(), + origin.above().east(), + origin.above().north().west(), + origin.above().north().east(), + origin.above().south().west(), + origin.above().south().east(), + origin.above(), + ] + ); + } + + #[test] + fn charge_helpers_match_vanilla_bounds() { + init_test_registry(); + + let empty = vanilla_blocks::RESPAWN_ANCHOR.default_state(); + let partial = empty.set_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES, 1); + let full = empty.set_value(&BlockStateProperties::RESPAWN_ANCHOR_CHARGES, 4); + + assert!(!RespawnAnchorBlock::has_charge(empty)); + assert!(RespawnAnchorBlock::can_be_charged(empty)); + assert!(RespawnAnchorBlock::has_charge(partial)); + assert!(RespawnAnchorBlock::can_be_charged(partial)); + assert!(RespawnAnchorBlock::has_charge(full)); + assert!(!RespawnAnchorBlock::can_be_charged(full)); + } + + #[test] + fn charge_consume_after_respawn_matches_vanilla_condition() { + assert!(RespawnAnchorBlock::should_consume_charge_after_respawn( + false, true, true, + )); + assert!(!RespawnAnchorBlock::should_consume_charge_after_respawn( + true, true, true, + )); + assert!(!RespawnAnchorBlock::should_consume_charge_after_respawn( + false, false, true, + )); + assert!(!RespawnAnchorBlock::should_consume_charge_after_respawn( + false, true, false, + )); + } +} diff --git a/steel-core/src/behavior/blocks/decoration/banner_block.rs b/steel-core/src/behavior/blocks/decoration/banner_block.rs new file mode 100644 index 000000000000..02b645ff45c4 --- /dev/null +++ b/steel-core/src/behavior/blocks/decoration/banner_block.rs @@ -0,0 +1,117 @@ +use steel_macros::block_behavior; +use steel_registry::REGISTRY; +use steel_registry::blocks::properties::{BlockStateProperties, Direction}; +use steel_registry::blocks::{BlockRef, block_state_ext::BlockStateExt}; +use steel_registry::vanilla_blocks; +use steel_utils::angle::convert_to_rotation_segment; +use steel_utils::{BlockPos, BlockStateId}; + +use crate::behavior::{BlockBehavior, BlockPlaceContext}; +use crate::world::{LevelReader, ScheduledTickAccess}; + +/// Shared behavior for standing banner blocks +#[block_behavior] +pub struct BannerBlock { + block: BlockRef, +} + +impl BannerBlock { + /// Creates a new banner block behavior + #[must_use] + pub const fn new(block: BlockRef) -> Self { + Self { block } + } +} + +impl BlockBehavior for BannerBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + + fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool { + world.get_block_state(pos.below()).is_solid() + } + + fn update_shape( + &self, + state: BlockStateId, + world: &dyn ScheduledTickAccess, + pos: BlockPos, + direction: Direction, + _neighbor_pos: BlockPos, + _neighbor_state: BlockStateId, + ) -> BlockStateId { + if direction == Direction::Down && !self.can_survive(state, world, pos) { + return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR); + } + state + } + + fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option { + Some(self.block.default_state().set_value( + &BlockStateProperties::ROTATION_16, + convert_to_rotation_segment(context.rotation + 180.0), + )) + } +} + +/// Shared behavior for wall banner blocks +#[block_behavior] +pub struct WallBannerBlock { + block: BlockRef, +} + +impl WallBannerBlock { + /// Creates a new wall banner block behavior + #[must_use] + pub const fn new(block: BlockRef) -> Self { + Self { block } + } +} + +impl BlockBehavior for WallBannerBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + + fn can_survive(&self, state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool { + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + world + .get_block_state(facing.opposite().relative(pos)) + .is_solid() + } + + fn update_shape( + &self, + state: BlockStateId, + world: &dyn ScheduledTickAccess, + pos: BlockPos, + direction: Direction, + _neighbor_pos: BlockPos, + _neighbor_state: BlockStateId, + ) -> BlockStateId { + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + if direction == facing.opposite() && !self.can_survive(state, world, pos) { + return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR); + } + state + } + + fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option { + for direction in context.get_nearest_looking_directions() { + if !direction.is_horizontal() { + continue; + } + + let state = self.block.default_state().set_value( + &BlockStateProperties::HORIZONTAL_FACING, + direction.opposite(), + ); + if self.can_survive(state, context.world.as_ref(), context.place_pos) { + return Some(state); + } + } + + None + } +} diff --git a/steel-core/src/behavior/blocks/decoration/mod.rs b/steel-core/src/behavior/blocks/decoration/mod.rs index 62858e7a7246..d871c518a764 100644 --- a/steel-core/src/behavior/blocks/decoration/mod.rs +++ b/steel-core/src/behavior/blocks/decoration/mod.rs @@ -1,3 +1,4 @@ +mod banner_block; mod cake_block; mod candle_block; mod candle_cake_block; @@ -5,6 +6,7 @@ mod chain_block; mod sign_block; mod torch_block; +pub use banner_block::{BannerBlock, WallBannerBlock}; pub use cake_block::CakeBlock; pub use candle_block::CandleBlock; pub use candle_cake_block::CandleCakeBlock; diff --git a/steel-core/src/behavior/blocks/decoration/sign_block.rs b/steel-core/src/behavior/blocks/decoration/sign_block.rs index 8f5f7d28db8e..a295c6fee467 100644 --- a/steel-core/src/behavior/blocks/decoration/sign_block.rs +++ b/steel-core/src/behavior/blocks/decoration/sign_block.rs @@ -294,6 +294,10 @@ impl StandingSignBlock { } impl BlockBehavior for StandingSignBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + fn update_shape( &self, state: BlockStateId, @@ -375,6 +379,10 @@ impl WallSignBlock { } impl BlockBehavior for WallSignBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + fn update_shape( &self, state: BlockStateId, @@ -464,6 +472,10 @@ impl CeilingHangingSignBlock { } impl BlockBehavior for CeilingHangingSignBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + fn update_shape( &self, state: BlockStateId, @@ -599,6 +611,10 @@ impl WallHangingSignBlock { } impl BlockBehavior for WallHangingSignBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + fn update_shape( &self, state: BlockStateId, diff --git a/steel-core/src/behavior/blocks/mod.rs b/steel-core/src/behavior/blocks/mod.rs index c61396c4b0f9..40b106ae760c 100644 --- a/steel-core/src/behavior/blocks/mod.rs +++ b/steel-core/src/behavior/blocks/mod.rs @@ -16,25 +16,29 @@ pub mod vegetation; pub use building::{ AmethystClusterBlock, BarrierBlock, BedBlock, BuddingAmethystBlock, CampfireBlock, DoorBlock, FenceBlock, FenceGateBlock, HayBlock, HoneyBlock, IronBarsBlock, LavaCauldronBlock, MagmaBlock, - PotentSulfurBlock, PowderSnowBlock, RotatedPillarBlock, ScaffoldingBlock, SlabBlock, - SlimeBlock, SpongeBlock, StairBlock, TrapDoorBlock, WallBlock, WaterloggedTransparentBlock, - WeatherState, WeatheringCopper, WeatheringCopperBarsBlock, WeatheringCopperDoorBlock, - WeatheringCopperFullBlock, WeatheringCopperGrateBlock, WeatheringCopperSlabBlock, - WeatheringCopperStairBlock, WeatheringCopperTrapDoorBlock, WetSpongeBlock, + PotentSulfurBlock, PowderSnowBlock, RespawnAnchorBlock, RotatedPillarBlock, ScaffoldingBlock, + SlabBlock, SlimeBlock, SpongeBlock, StairBlock, TrapDoorBlock, WallBlock, + WaterloggedTransparentBlock, WeatherState, WeatheringCopper, WeatheringCopperBarsBlock, + WeatheringCopperDoorBlock, WeatheringCopperFullBlock, WeatheringCopperGrateBlock, + WeatheringCopperSlabBlock, WeatheringCopperStairBlock, WeatheringCopperTrapDoorBlock, + WetSpongeBlock, }; pub use colored::StainedGlassPaneBlock; pub use container::{BarrelBlock, BeehiveBlock, CraftingTableBlock}; pub use decoration::{ - CakeBlock, CandleBlock, CandleCakeBlock, CeilingHangingSignBlock, ChainBlock, - StandingSignBlock, TorchBlock, WallHangingSignBlock, WallSignBlock, WallTorchBlock, - WeatheringCopperChainBlock, + BannerBlock, CakeBlock, CandleBlock, CandleCakeBlock, CeilingHangingSignBlock, ChainBlock, + StandingSignBlock, TorchBlock, WallBannerBlock, WallHangingSignBlock, WallSignBlock, + WallTorchBlock, WeatheringCopperChainBlock, }; pub use fluid::{BubbleColumnBlock, LiquidBlock}; pub use portal::{ EndGatewayBlock, EndPortalBlock, EndPortalFrameBlock, FireBlock, NetherPortalBlock, SoulFireBlock, }; -pub use redstone::{ButtonBlock, RedstoneTorchBlock, RedstoneWallTorchBlock}; +pub use redstone::{ + ButtonBlock, PressurePlateBlock, RedstoneTorchBlock, RedstoneWallTorchBlock, + WeightedPressurePlateBlock, +}; pub use vegetation::{ AzaleaBlock, BambooSaplingBlock, BambooStalkBlock, BeetrootBlock, CactusBlock, CactusFlowerBlock, CarrotBlock, CocoaBlock, CropBlock, DoublePlantBlock, FlowerBlock, diff --git a/steel-core/src/behavior/blocks/redstone/mod.rs b/steel-core/src/behavior/blocks/redstone/mod.rs index ea3e711a193d..5c141a0e63d1 100644 --- a/steel-core/src/behavior/blocks/redstone/mod.rs +++ b/steel-core/src/behavior/blocks/redstone/mod.rs @@ -1,5 +1,7 @@ mod button_block; +mod pressure_plate_block; mod redstone_torch_block; pub use button_block::ButtonBlock; +pub use pressure_plate_block::{PressurePlateBlock, WeightedPressurePlateBlock}; pub use redstone_torch_block::{RedstoneTorchBlock, RedstoneWallTorchBlock}; diff --git a/steel-core/src/behavior/blocks/redstone/pressure_plate_block.rs b/steel-core/src/behavior/blocks/redstone/pressure_plate_block.rs new file mode 100644 index 000000000000..2398971a722e --- /dev/null +++ b/steel-core/src/behavior/blocks/redstone/pressure_plate_block.rs @@ -0,0 +1,230 @@ +use std::sync::Arc; + +use steel_macros::block_behavior; +use steel_registry::REGISTRY; +use steel_registry::blocks::BlockRef; +use steel_registry::blocks::block_state_ext::BlockStateExt; +use steel_registry::blocks::properties::{BlockStateProperties, Direction}; +use steel_registry::blocks::shapes::SupportType; +use steel_registry::vanilla_blocks; +use steel_utils::BlockPos; +use steel_utils::BlockStateId; + +use crate::behavior::{BlockBehavior, BlockPlaceContext}; +use crate::world::{LevelReader, ScheduledTickAccess, World}; + +/// Shared vanilla behavior for pressure plate blocks +struct BasePressurePlateBehavior { + block: BlockRef, +} + +impl BasePressurePlateBehavior { + const fn new(block: BlockRef) -> Self { + Self { block } + } + + fn can_survive(world: &dyn LevelReader, pos: BlockPos) -> bool { + let below_pos = pos.below(); + let below_state = world.get_block_state(below_pos); + below_state.is_face_sturdy_for_at(below_pos, Direction::Up, SupportType::Rigid) + || below_state.is_face_sturdy_for_at(below_pos, Direction::Up, SupportType::Center) + } + + fn update_shape( + state: BlockStateId, + world: &dyn ScheduledTickAccess, + pos: BlockPos, + direction: Direction, + ) -> BlockStateId { + if direction == Direction::Down && !Self::can_survive(world, pos) { + return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR); + } + state + } + + fn update_neighbors(&self, world: &Arc, pos: BlockPos) { + world.update_neighbors_at(pos, self.block); + world.update_neighbors_at(pos.below(), self.block); + } + + // TODO: Implement vanilla checkPressed once World exposes a compatible entity query + // with EntitySelector.NO_SPECTATORS and isIgnoringBlockTriggers filtering. + // This should set the new signal, update neighbors, play pressure plate sounds, + // emit BLOCK_ACTIVATE/BLOCK_DEACTIVATE game events, and schedule the next tick + // while pressed. +} + +/// Shared behavior for unweighted pressure plate blocks +#[block_behavior] +pub struct PressurePlateBlock { + base: BasePressurePlateBehavior, +} + +impl PressurePlateBlock { + /// Creates a new pressure plate block behavior + #[must_use] + pub const fn new(block: BlockRef) -> Self { + Self { + base: BasePressurePlateBehavior::new(block), + } + } + + fn get_signal_for_state(state: BlockStateId) -> i32 { + if state.get_value(&BlockStateProperties::POWERED) { + 15 + } else { + 0 + } + } + + fn set_signal_for_state(state: BlockStateId, signal: i32) -> BlockStateId { + state.set_value(&BlockStateProperties::POWERED, signal > 0) + } +} + +impl BlockBehavior for PressurePlateBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + + fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool { + BasePressurePlateBehavior::can_survive(world, pos) + } + + fn update_shape( + &self, + state: BlockStateId, + world: &dyn ScheduledTickAccess, + pos: BlockPos, + direction: Direction, + _neighbor_pos: BlockPos, + _neighbor_state: BlockStateId, + ) -> BlockStateId { + BasePressurePlateBehavior::update_shape(state, world, pos, direction) + } + + fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option { + let default_state = Self::set_signal_for_state(self.base.block.default_state(), 0); + if !self.can_survive(default_state, context.world, context.place_pos) { + return None; + } + Some(default_state) + } + + fn tick(&self, state: BlockStateId, _world: &Arc, _pos: BlockPos) { + if Self::get_signal_for_state(state) != 0 { + // TODO: Call vanilla-equivalent checkPressed for unweighted plates once + // entity query/filtering exists. + } + } + + fn affect_neighbors_after_removal( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + moved_by_piston: bool, + ) { + if !moved_by_piston && Self::get_signal_for_state(state) > 0 { + self.base.update_neighbors(world, pos); + } + } + + // TODO: Mirror vanilla getSignal, getDirectSignal, and isSignalSource once + // BlockBehavior has a redstone signal API. +} + +/// Shared behavior for weighted pressure plate blocks +#[block_behavior] +pub struct WeightedPressurePlateBlock { + base: BasePressurePlateBehavior, + #[json_arg(value, json = "max_weight")] + max_weight: i32, +} + +impl WeightedPressurePlateBlock { + /// Creates a new weighted pressure plate block behavior + #[must_use] + pub const fn new(block: BlockRef, max_weight: i32) -> Self { + Self { + base: BasePressurePlateBehavior::new(block), + max_weight, + } + } + + /// Returns the scheduled recheck delay for weighted plates + #[must_use] + pub const fn pressed_time() -> i32 { + 10 + } + + /// Converts a filtered entity count to the vanilla weighted redstone signal + #[must_use] + pub fn signal_strength_from_entity_count(&self, count: i32) -> i32 { + let count = count.min(self.max_weight); + if count <= 0 { + return 0; + } + (count * 15 + self.max_weight - 1) / self.max_weight + } + + fn get_signal_for_state(state: BlockStateId) -> i32 { + i32::from(state.get_value(&BlockStateProperties::POWER)) + } + + fn set_signal_for_state(state: BlockStateId, signal: u8) -> BlockStateId { + state.set_value(&BlockStateProperties::POWER, signal) + } +} + +impl BlockBehavior for WeightedPressurePlateBlock { + fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool { + true + } + + fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool { + BasePressurePlateBehavior::can_survive(world, pos) + } + + fn update_shape( + &self, + state: BlockStateId, + world: &dyn ScheduledTickAccess, + pos: BlockPos, + direction: Direction, + _neighbor_pos: BlockPos, + _neighbor_state: BlockStateId, + ) -> BlockStateId { + BasePressurePlateBehavior::update_shape(state, world, pos, direction) + } + + fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option { + let default_state = Self::set_signal_for_state(self.base.block.default_state(), 0); + if !self.can_survive(default_state, context.world, context.place_pos) { + return None; + } + Some(default_state) + } + + fn tick(&self, state: BlockStateId, _world: &Arc, _pos: BlockPos) { + if Self::get_signal_for_state(state) != 0 { + // TODO: Call vanilla-equivalent checkPressed for weighted plates once + // entity query/filtering exists. + } + } + + fn affect_neighbors_after_removal( + &self, + state: BlockStateId, + world: &Arc, + pos: BlockPos, + moved_by_piston: bool, + ) { + if !moved_by_piston && Self::get_signal_for_state(state) > 0 { + self.base.update_neighbors(world, pos); + } + } + + // TODO: Mirror vanilla getSignal, getDirectSignal, and isSignalSource once + // BlockBehavior has a redstone signal API. +} diff --git a/steel-core/src/behavior/items/bed_item.rs b/steel-core/src/behavior/items/bed_item.rs new file mode 100644 index 000000000000..18a1e0b2e871 --- /dev/null +++ b/steel-core/src/behavior/items/bed_item.rs @@ -0,0 +1,44 @@ +use steel_macros::item_behavior; +use steel_registry::blocks::BlockRef; +use steel_utils::{BlockStateId, types::UpdateFlags}; + +use crate::behavior::ItemBehavior; +use crate::behavior::context::{BlockPlaceContext, InteractionResult, UseOnContext}; +use crate::behavior::items::BlockItem; + +/// Behavior for beds +#[item_behavior] +pub struct BedItem { + #[json_arg(vanilla_blocks, json = "block")] + _block: BlockRef, + base: BlockItem, +} + +impl BedItem { + const PLACE_BLOCK_FLAGS: UpdateFlags = UpdateFlags::UPDATE_CLIENTS + .union(UpdateFlags::UPDATE_IMMEDIATE) + .union(UpdateFlags::UPDATE_KNOWN_SHAPE); + + /// New bed item behavior + #[must_use] + pub const fn new(block: BlockRef) -> Self { + Self { + _block: block, + base: BlockItem::new(block), + } + } + + fn place_block(context: &BlockPlaceContext<'_>, state: BlockStateId) -> bool { + context + .world + .set_block(context.place_pos, state, Self::PLACE_BLOCK_FLAGS) + } +} + +impl ItemBehavior for BedItem { + fn use_on(&self, context: &mut UseOnContext) -> InteractionResult { + self.base.place_with(context, |place_context, state| { + Self::place_block(place_context, state) + }) + } +} diff --git a/steel-core/src/behavior/items/block_item.rs b/steel-core/src/behavior/items/block_item.rs index eddae58f385c..5668a2c2e5c8 100644 --- a/steel-core/src/behavior/items/block_item.rs +++ b/steel-core/src/behavior/items/block_item.rs @@ -30,7 +30,7 @@ impl BlockItem { Self { block } } - fn place_with( + pub(super) fn place_with( &self, context: &mut UseOnContext<'_>, place_block: impl FnOnce(&BlockPlaceContext<'_>, BlockStateId) -> bool, diff --git a/steel-core/src/behavior/items/mod.rs b/steel-core/src/behavior/items/mod.rs index efa0956705c6..deb1344577c2 100644 --- a/steel-core/src/behavior/items/mod.rs +++ b/steel-core/src/behavior/items/mod.rs @@ -4,6 +4,7 @@ //! See `src/behavior/generated/items.rs` for the generated registration code. mod axe; +mod bed_item; mod block_item; mod bonemeal; mod bucket; @@ -22,6 +23,7 @@ mod standing_and_wall_block_item; mod flint_and_steel; pub use axe::AxeItem; +pub use bed_item::BedItem; pub use block_item::{BlockItem, DoubleHighBlockItem}; pub use bonemeal::BoneMealItem; pub use bucket::BucketItem; diff --git a/steel-core/src/behavior/mod.rs b/steel-core/src/behavior/mod.rs index 2ed61ce58d3c..1622171b4021 100644 --- a/steel-core/src/behavior/mod.rs +++ b/steel-core/src/behavior/mod.rs @@ -76,8 +76,8 @@ pub use fluid::{FLUID_BEHAVIORS, FluidBehaviorRegistry}; pub use item::{ItemBehavior, ItemBehaviorRegistry}; use item_behaviors::register_item_behaviors; pub use items::{ - BlockItem, BucketItem, DefaultItemBehavior, DoubleHighBlockItem, EnderEyeItem, HangingSignItem, - ShovelItem, SignItem, StandingAndWallBlockItem, + BedItem, BlockItem, BucketItem, DefaultItemBehavior, DoubleHighBlockItem, EnderEyeItem, + HangingSignItem, ShovelItem, SignItem, StandingAndWallBlockItem, }; use std::ops::Deref; use std::sync::OnceLock; @@ -133,6 +133,9 @@ pub trait BlockStateBehaviorExt { /// Returns whether this block state is pathfindable for the supplied vanilla computation type. fn is_pathfindable(&self, computation_type: PathComputationType) -> bool; + + /// Returns whether this block state can be occupied by a forced respawn position + fn is_possible_to_respawn_in_this(&self) -> bool; } impl BlockStateBehaviorExt for BlockStateId { @@ -163,6 +166,12 @@ impl BlockStateBehaviorExt for BlockStateId { let behavior = BLOCK_BEHAVIORS.get_behavior(block); behavior.is_pathfindable(*self, computation_type) } + + fn is_possible_to_respawn_in_this(&self) -> bool { + let block = self.get_block(); + let behavior = BLOCK_BEHAVIORS.get_behavior(block); + behavior.is_possible_to_respawn_in_this(*self) + } } /// Global block behavior registry. diff --git a/steel-core/src/entity/dismount_helper.rs b/steel-core/src/entity/dismount_helper.rs new file mode 100644 index 000000000000..c68b91e1d778 --- /dev/null +++ b/steel-core/src/entity/dismount_helper.rs @@ -0,0 +1,215 @@ +//! Vanilla dismount location helper + +use std::sync::Arc; + +use glam::DVec3; +use steel_registry::blocks::{ + block_state_ext::BlockStateExt, + properties::BlockStateProperties, + shapes::{OffsetVoxelShape, VoxelShape}, +}; +use steel_registry::entity_data::EntityPose; +use steel_registry::vanilla_block_tags::BlockTag; +use steel_registry::{vanilla_blocks, vanilla_entities}; +use steel_utils::{BlockPos, BlockStateId, WorldAabb, axis::Axis}; + +use crate::behavior::{BLOCK_BEHAVIORS, BlockCollisionContext}; +use crate::entity::Entity; +use crate::entity::ai::walk::WalkPathEvaluator; +use crate::physics::{CollisionWorld, WorldCollisionProvider}; +use crate::world::World; + +#[must_use] +#[expect( + dead_code, + reason = "vanilla DismountHelper foundation; vehicle dismounts use this next" +)] +pub(crate) const fn offsets_for_direction(forward: steel_utils::Direction) -> [(i32, i32); 8] { + let right = forward.rotate_y_clockwise(); + let left = right.opposite(); + let back = forward.opposite(); + let (right_x, right_z) = right.offset_xz(); + let (left_x, left_z) = left.offset_xz(); + let (back_x, back_z) = back.offset_xz(); + let (forward_x, forward_z) = forward.offset_xz(); + + [ + (right_x, right_z), + (left_x, left_z), + (back_x + right_x, back_z + right_z), + (back_x + left_x, back_z + left_z), + (forward_x + right_x, forward_z + right_z), + (forward_x + left_x, forward_z + left_z), + (back_x, back_z), + (forward_x, forward_z), + ] +} + +#[must_use] +pub(crate) fn can_dismount_to(world: &Arc, aabb: &WorldAabb) -> bool { + let collision_world = WorldCollisionProvider::new(world); + !collision_world.has_block_collision_with_context(aabb, BlockCollisionContext::empty()) + && world.world_border_snapshot().is_within_bounds(*aabb) +} + +#[must_use] +#[expect( + dead_code, + reason = "vanilla DismountHelper foundation; vehicle dismounts use this next" +)] +pub(crate) fn can_dismount_to_pose( + world: &Arc, + location: DVec3, + passenger: &dyn Entity, + dismount_pose: EntityPose, +) -> bool { + let dimensions = passenger.dimensions_for_pose(dismount_pose); + let aabb = WorldAabb::entity_box( + location.x, + location.y, + location.z, + f64::from(dimensions.half_width()), + f64::from(dimensions.height), + ); + can_dismount_to(world, &aabb) +} + +#[must_use] +#[expect( + dead_code, + reason = "vanilla DismountHelper foundation; vehicle dismounts use this next" +)] +pub(crate) fn find_ceiling_from( + pos: BlockPos, + blocks: i32, + mut shape_getter: impl FnMut(BlockPos) -> OffsetVoxelShape, +) -> f64 { + let mut y = 0; + while y < blocks { + let cursor = BlockPos::new(pos.x(), pos.y() + y, pos.z()); + let collision_shape = shape_getter(cursor); + if !collision_shape.is_empty() { + return f64::from(pos.y() + y) + collision_shape.min(Axis::Y); + } + y += 1; + } + + f64::INFINITY +} + +#[must_use] +pub(crate) fn find_safe_dismount_location( + world: &Arc, + entity: &dyn Entity, + block_pos: BlockPos, + check_dangerous: bool, +) -> Option { + if check_dangerous && is_block_dangerous(entity, world.get_block_state(block_pos)) { + return None; + } + + let floor_height = block_floor_height( + non_climbable_shape(world, block_pos), + non_climbable_shape(world, block_pos.below()), + ); + if !is_block_floor_valid(floor_height) { + return None; + } + + if check_dangerous + && floor_height <= 0.0 + && is_block_dangerous(entity, world.get_block_state(block_pos.below())) + { + return None; + } + + let position = DVec3::new( + f64::from(block_pos.x()) + 0.5, + f64::from(block_pos.y()) + floor_height, + f64::from(block_pos.z()) + 0.5, + ); + let dimensions = entity.dimensions_for_pose(EntityPose::Standing); + let aabb = WorldAabb::entity_box( + position.x, + position.y, + position.z, + f64::from(dimensions.half_width()), + f64::from(dimensions.height), + ); + if !can_dismount_to(world, &aabb) { + return None; + } + + if entity.entity_type() == &vanilla_entities::PLAYER + && (world + .get_block_state(block_pos) + .get_block() + .has_tag(&BlockTag::INVALID_SPAWN_INSIDE) + || world + .get_block_state(block_pos.above()) + .get_block() + .has_tag(&BlockTag::INVALID_SPAWN_INSIDE)) + { + return None; + } + + Some(position) +} + +fn non_climbable_shape(world: &Arc, pos: BlockPos) -> OffsetVoxelShape { + let state = world.get_block_state(pos); + let block = state.get_block(); + let is_open_trapdoor = block.has_tag(&BlockTag::TRAPDOORS) + && state.try_get_value(&BlockStateProperties::OPEN) == Some(true); + + if block.has_tag(&BlockTag::CLIMBABLE) || is_open_trapdoor { + return OffsetVoxelShape::without_offset(VoxelShape::EMPTY); + } + + let behavior = BLOCK_BEHAVIORS.get_behavior(block); + let shape = + behavior.get_collision_shape(state, world.as_ref(), pos, BlockCollisionContext::empty()); + if shape.is_empty() { + return OffsetVoxelShape::without_offset(shape); + } + + OffsetVoxelShape::new( + shape, + behavior.get_collision_shape_offset( + state, + world.as_ref(), + pos, + BlockCollisionContext::empty(), + ), + ) +} + +fn block_floor_height(block_shape: OffsetVoxelShape, below_block_shape: OffsetVoxelShape) -> f64 { + if !block_shape.is_empty() { + return block_shape.max(Axis::Y); + } + + let below_floor = below_block_shape.max(Axis::Y); + if below_floor >= 1.0 { + below_floor - 1.0 + } else { + f64::NEG_INFINITY + } +} + +fn is_block_floor_valid(block_floor_height: f64) -> bool { + !block_floor_height.is_infinite() && block_floor_height < 1.0 +} + +fn is_block_dangerous(entity: &dyn Entity, state: BlockStateId) -> bool { + // TODO: mirror vanilla entitytype.immuneto when the entity types carry entity specific immune block tag + if !entity.fire_immune() && WalkPathEvaluator::is_burning_block(state) { + return true; + } + + let block = state.get_block(); + block == &vanilla_blocks::WITHER_ROSE + || block == &vanilla_blocks::SWEET_BERRY_BUSH + || block == &vanilla_blocks::CACTUS + || block == &vanilla_blocks::POWDER_SNOW +} diff --git a/steel-core/src/entity/entities/pig.rs b/steel-core/src/entity/entities/pig.rs index 41ede278244b..979559101eb5 100644 --- a/steel-core/src/entity/entities/pig.rs +++ b/steel-core/src/entity/entities/pig.rs @@ -37,8 +37,8 @@ use crate::entity::damage::DamageSource; use crate::entity::{ AgeableMob, AgeableMobBase, Animal, AnimalBase, Entity, EntityBase, EntityBaseLoad, EntityPose, EntitySpawnReason, EntitySyncedData, ItemBasedSteering, ItemSteerable, LivingEntity, - LivingEntityBase, Mob, MobBase, MobEffectSyncChange, PathfinderMob, SharedEntity, - SpawnGroupData, + LivingEntityBase, LivingEntitySyncedData, Mob, MobBase, MobEffectSyncChange, PathfinderMob, + SharedEntity, SpawnGroupData, }; use crate::inventory::equipment::EquipmentSlot; use crate::physics::MoveResult; @@ -544,6 +544,10 @@ impl LivingEntity for PigEntity { &self.living_base } + fn living_synced_data(&self) -> Option<&dyn LivingEntitySyncedData> { + Some(&self.entity_data) + } + fn get_health(&self) -> f32 { *self.entity_data.lock().living_entity().health.get() } diff --git a/steel-core/src/entity/mod.rs b/steel-core/src/entity/mod.rs index 51b9e5ab4b60..73157ec09418 100644 --- a/steel-core/src/entity/mod.rs +++ b/steel-core/src/entity/mod.rs @@ -40,17 +40,17 @@ use steel_registry::{RegistryEntry, RegistryExt}; use steel_registry::{vanilla_attributes, vanilla_fluid_tags, vanilla_items, vanilla_mob_effects}; use steel_utils::entity_events::EntityStatus; use steel_utils::locks::SyncMutex; -use steel_utils::types::{Difficulty, InteractionHand}; +use steel_utils::types::{Difficulty, InteractionHand, UpdateFlags}; use steel_utils::{ BlockPos, BlockStateId, ChunkPos, Direction, ErasedType, Identifier, WorldAabb, axis::Axis, - block_util::FoundRectangle, + block_util::FoundRectangle, wrap_degrees, }; use text_components::TextComponent; use uuid::Uuid; use crate::behavior::{ BLOCK_BEHAVIORS, BlockCollisionContext, BlockStateBehaviorExt as _, EntityFallOnContext, - EntityLandingContext, FLUID_BEHAVIORS, InteractionResult, + EntityLandingContext, FLUID_BEHAVIORS, InteractionResult, blocks::BedBlock, }; use crate::chunk_saver::ChunkStorage; use crate::entity::attribute::{AttributeMap, AttributeModifier, AttributeModifierOperation}; @@ -695,6 +695,7 @@ mod base; mod block_effects; mod callback; pub mod damage; +pub(crate) mod dismount_helper; pub mod entities; mod fluid_contact; #[expect(warnings)] @@ -765,7 +766,7 @@ pub use registry::{ENTITIES, EntityLoadRequest, EntityRegistry, init_entities}; pub(crate) use shared_flags::EntitySharedFlags; pub(crate) use spawn::{AgeableMobGroupData, EntitySpawnReason, SpawnGroupData}; pub(crate) use storage::EntityStorage; -pub use synced_data::EntitySyncedData; +pub use synced_data::{EntitySyncedData, LivingEntitySyncedData}; pub(crate) use ticking::{ snapshot_old_pos_and_rot_for_tick, tick_vehicle_passengers_with_ticked_if, }; @@ -1317,6 +1318,9 @@ pub struct EntityCapabilities<'a> { living: Option<&'a dyn LivingEntity>, mob: Option<&'a dyn Mob>, pathfinder_mob: Option<&'a dyn PathfinderMob>, + // TODO: Add a Monster capability for vanilla Monster.class checks such as + // ServerPlayer.startSleepInBed. This must be class-based, not inferred + // from entity type tags, MobCategory::Monster, or Enemy-like behavior. animal: Option<&'a dyn Animal>, item_steerable: Option<&'a dyn ItemSteerable>, item_merge_entity: Option<&'a dyn ItemMergeEntity>, @@ -6921,11 +6925,17 @@ pub trait LivingEntity: Entity { /// Sets the vanilla living-entity sleeping position. fn set_sleeping_pos(&self, bed_position: BlockPos) { self.living_base().set_sleeping_pos(bed_position); + if let Some(entity_data) = self.living_synced_data() { + entity_data.set_sleeping_pos(bed_position); + } } /// Clears the vanilla living-entity sleeping position. fn clear_sleeping_pos(&self) { self.living_base().clear_sleeping_pos(); + if let Some(entity_data) = self.living_synced_data() { + entity_data.clear_sleeping_pos(); + } } /// Checks if the entity is sleeping. @@ -6933,9 +6943,95 @@ pub trait LivingEntity: Entity { self.sleeping_pos().is_some() } + /// Returns synchronized data declared by vanilla `LivingEntity`. + fn living_synced_data(&self) -> Option<&dyn LivingEntitySyncedData> { + None + } + + /// Starts sleeping at the given bed position. + fn start_sleeping(&self, bed_position: BlockPos) -> Result<(), EntityMoveError> { + if self.is_passenger() { + self.stop_riding(); + } + + let Some(world) = self.level() else { + return Err(EntityMoveError::NotLive { + entity_id: self.id(), + }); + }; + let block_state = world.get_block_state(bed_position); + if block_state.get_block().has_tag(&BlockTag::BEDS) { + world.set_block( + bed_position, + block_state.set_value(&BlockStateProperties::OCCUPIED, true), + UpdateFlags::UPDATE_ALL, + ); + } + + self.try_set_position(DVec3::new( + f64::from(bed_position.x()) + 0.5, + f64::from(bed_position.y()) + 0.6875, + f64::from(bed_position.z()) + 0.5, + ))?; + self.set_pose(EntityPose::Sleeping); + self.set_sleeping_pos(bed_position); + self.set_velocity(DVec3::ZERO); + Ok(()) + } + + /// Shared body for overrides that need vanilla `super.stopSleeping()`. + fn default_stop_sleeping(&self) { + if let Some(bed_position) = self.sleeping_pos() + && let Some(world) = self.level() + { + let state = world.get_block_state(bed_position); + if state.get_block().has_tag(&BlockTag::BEDS) { + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + world.set_block( + bed_position, + state.set_value(&BlockStateProperties::OCCUPIED, false), + UpdateFlags::UPDATE_ALL, + ); + let stand_up = BedBlock::find_standup_position( + &world, + self.as_entity_event_source(), + facing, + bed_position, + ) + .unwrap_or_else(|| { + let above = bed_position.above(); + DVec3::new( + f64::from(above.x()) + 0.5, + f64::from(above.y()) + 0.1, + f64::from(above.z()) + 0.5, + ) + }); + let bed_center = DVec3::new( + f64::from(bed_position.x()) + 0.5, + f64::from(bed_position.y()), + f64::from(bed_position.z()) + 0.5, + ); + let look_direction = (bed_center - stand_up).normalize_or_zero(); + let yaw = wrap_degrees( + (look_direction.z.atan2(look_direction.x).to_degrees() - 90.0) as f32, + ); + if let Err(error) = self.try_set_position(stand_up) { + log::warn!( + "failed to move entity {} to bed stand-up position: {error}", + self.id() + ); + } + self.set_rotation((yaw, 0.0)); + } + } + + self.set_pose(EntityPose::Standing); + self.clear_sleeping_pos(); + } + /// Stops the entity from sleeping. fn stop_sleeping(&self) { - self.clear_sleeping_pos(); + self.default_stop_sleeping(); } /// Checks if the entity is sprinting. diff --git a/steel-core/src/entity/synced_data.rs b/steel-core/src/entity/synced_data.rs index 09511e4cbf83..54ce624f8d8e 100644 --- a/steel-core/src/entity/synced_data.rs +++ b/steel-core/src/entity/synced_data.rs @@ -1,7 +1,8 @@ use steel_registry::{ entity_data::{DataValue, EntityPose}, - vanilla_entity_data::VanillaEntityData, + vanilla_entity_data::{VanillaEntityData, VanillaLivingEntityData}, }; +use steel_utils::BlockPos; use steel_utils::locks::SyncMutex; use text_components::TextComponent; @@ -70,6 +71,31 @@ pub trait EntitySyncedData: Send + Sync { fn set_base_ticks_frozen(&self, ticks_frozen: i32); } +/// Thread-safe access to synchronized data declared by vanilla `LivingEntity`. +pub trait LivingEntitySyncedData: EntitySyncedData { + /// Sets synchronized vanilla sleeping position. + fn set_sleeping_pos(&self, sleeping_pos: BlockPos); + + /// Clears synchronized vanilla sleeping position. + fn clear_sleeping_pos(&self); +} + +impl LivingEntitySyncedData for SyncMutex +where + T: VanillaLivingEntityData + Send + Sync, +{ + fn set_sleeping_pos(&self, sleeping_pos: BlockPos) { + self.lock() + .living_entity_mut() + .sleeping_pos + .set(Some(sleeping_pos)); + } + + fn clear_sleeping_pos(&self) { + self.lock().living_entity_mut().sleeping_pos.set(None); + } +} + impl EntitySyncedData for SyncMutex where T: VanillaEntityData + Send + Sync, @@ -197,7 +223,10 @@ where #[cfg(test)] mod tests { - use steel_registry::{entity_data::EntityData, vanilla_entity_data::ItemEntityData}; + use steel_registry::{ + entity_data::EntityData, + vanilla_entity_data::{ItemEntityData, PlayerEntityData}, + }; use text_components::TextComponent; use super::*; @@ -329,4 +358,33 @@ mod tests { assert_eq!(values[4].index, 4); assert!(matches!(values[4].value, EntityData::Boolean(true))); } + + #[test] + fn living_synced_data_writes_sleeping_pos_layer() { + let data = SyncMutex::new(PlayerEntityData::new()); + let bed_pos = BlockPos::new(1, 64, 2); + + LivingEntitySyncedData::set_sleeping_pos(&data, bed_pos); + + let values = EntitySyncedData::pack_dirty(&data) + .expect("expected dirty living sleeping-pos metadata"); + assert_eq!(values.len(), 1); + assert_eq!(values[0].index, 14); + assert_eq!(values[0].serializer_id, 11); + assert!(matches!( + values[0].value, + EntityData::OptionalBlockPos(Some(pos)) if pos == bed_pos + )); + + LivingEntitySyncedData::clear_sleeping_pos(&data); + + let values = EntitySyncedData::pack_dirty(&data) + .expect("expected dirty cleared sleeping-pos metadata"); + assert_eq!(values.len(), 1); + assert_eq!(values[0].index, 14); + assert!(matches!( + values[0].value, + EntityData::OptionalBlockPos(None) + )); + } } diff --git a/steel-core/src/player/chat_state.rs b/steel-core/src/player/chat_state.rs index 825598a7a655..33d7a6386291 100644 --- a/steel-core/src/player/chat_state.rs +++ b/steel-core/src/player/chat_state.rs @@ -301,6 +301,11 @@ impl Player { self.send_packet(CSystemChatMessage::new(text, self, false)); } + /// Sends an overlay system message to the player + pub fn send_overlay_message(&self, text: &TextComponent) { + self.send_packet(CSystemChatMessage::new(text, self, true)); + } + /// Updates the player's chat session and initializes the message chain. /// /// This should be called when receiving a `ChatSessionUpdate` packet from the client. diff --git a/steel-core/src/player/mod.rs b/steel-core/src/player/mod.rs index ddd1a15e6fe0..cc97bc618276 100644 --- a/steel-core/src/player/mod.rs +++ b/steel-core/src/player/mod.rs @@ -29,6 +29,7 @@ pub mod player_data_storage; pub mod player_inventory; pub mod profile_key; mod signature_cache; +mod sleep_state; mod spam_throttler; mod teleport_state; mod tick_state; @@ -45,6 +46,7 @@ use lifecycle_state::PlayerLifecycleState; pub use message_validator::LastSeenMessagesValidator; use movement_state::MovementState; pub use signature_cache::{LastSeen, MessageCache}; +use sleep_state::PlayerSleepState; use steel_protocol::{ packet_traits::{CompressionInfo, EncodedPacket}, packets::game::{CCooldown, CLevelEvent, CSetEntityData, CSetExperience}, @@ -59,12 +61,15 @@ pub use game_profile::{GameProfile, GameProfileAction}; use std::sync::{Arc, Weak}; use steel_macros::entity_impl; use steel_protocol::packets::game::{ - AttributeSnapshot, CEntityEvent, CPlayerCombatKill, CRespawn, CSetDefaultSpawnPosition, - CSetHealth, CSetHeldSlot, CSetPassengers, CSetTime, ClientCommandAction, EquipmentSlotItem, - RelativeMovement, SoundSource, + AnimateAction, AttributeSnapshot, CAnimate, CEntityEvent, CPlayerCombatKill, CRespawn, + CSetDefaultSpawnPosition, CSetHealth, CSetHeldSlot, CSetPassengers, CSetTime, CSound, + ClientCommandAction, EquipmentSlotItem, RelativeMovement, SoundSource, }; use steel_registry::RegistryEntry; -use steel_registry::blocks::block_state_ext::BlockStateExt as _; +use steel_registry::blocks::{ + block_state_ext::BlockStateExt as _, properties::BlockStateProperties, +}; +use steel_registry::dimension_type::BedRuleValue; use steel_registry::entity_data::EntityPose; use steel_registry::entity_type::{EntityDimensions, EntityTypeRef}; use steel_registry::game_rules::{GameRuleRef, GameRuleValue}; @@ -76,8 +81,8 @@ use steel_registry::vanilla_game_rules::{ KEEP_INVENTORY, SHOW_DEATH_MESSAGES, }; use steel_registry::{ - level_events, sound_events, vanilla_attributes, vanilla_damage_type_tags, vanilla_entities, - vanilla_particle_types, + level_events, sound_events, vanilla_attributes, vanilla_blocks, vanilla_damage_type_tags, + vanilla_entities, vanilla_particle_types, }; use steel_utils::entity_events::EntityStatus; use uuid::Uuid; @@ -90,14 +95,18 @@ use text_components::resolving::TextResolutor; use text_components::translation::TranslatedMessage; use text_components::{content::Resolvable, custom::CustomData}; +use crate::behavior::BlockStateBehaviorExt as _; +use crate::behavior::blocks::{BedBlock, RespawnAnchorBlock}; use crate::chunk::chunk_request::{ChunkRequestHandle, ChunkRequestState}; use crate::config::RuntimeConfig; use crate::enchantment_helper; use crate::entity::damage::DamageSource; +use crate::entity::entities::ExperienceOrbEntity; use crate::entity::{ DEATH_DURATION, Entity, EntityBase, EntityEventSource, EntityMovementEmission, - EntitySyncedData, LivingEntity, LivingEntityBase, MobEffectSyncChange, MobEffectSyncPacket, - RemovalReason, SharedEntity, equipment_items_to_packet_items, start_riding_entities, + EntitySyncedData, LivingEntity, LivingEntityBase, LivingEntitySyncedData, MobEffectSyncChange, + MobEffectSyncPacket, RemovalReason, SharedEntity, equipment_items_to_packet_items, + start_riding_entities, }; use crate::fluid::get_fluid_state; use crate::inventory::{SyncPlayerInv, equipment::EquipmentSlot}; @@ -119,7 +128,9 @@ use steel_protocol::packets::{ }; use steel_registry::item_stack::ItemStack; -use steel_utils::{BlockPos, BlockStateId, ChunkPos, DowncastType, DowncastTypeKey, Identifier}; +use steel_utils::{ + BlockPos, BlockStateId, ChunkPos, DowncastType, DowncastTypeKey, Identifier, wrap_degrees, +}; use crate::inventory::{MenuInstance, container::Container, inventory_menu::InventoryMenu}; @@ -129,6 +140,25 @@ pub type PreviousMessageEntry = PreviousMessage; pub use steel_protocol::packets::common::{ChatVisibility, HumanoidArm, ParticleStatus}; const RESPAWN_SEARCH_READY_CANDIDATE_BUDGET: usize = 8; +const BED_INTERACTION_XZ_RANGE: f64 = 3.0; +const BED_INTERACTION_Y_RANGE: f64 = 2.0; + +/// Result problem returned by sleep admission checks. +#[derive(Debug)] +pub(crate) enum BedSleepingProblem { + OtherProblem, + Message(Box), +} + +impl BedSleepingProblem { + #[must_use] + pub(crate) fn message(&self) -> Option<&TextComponent> { + match self { + Self::Message(message) => Some(message.as_ref()), + Self::OtherProblem => None, + } + } +} /// Client-side settings sent via `SClientInformation` packet. /// This is stored separately from the packet struct to allow default initialization. @@ -260,6 +290,12 @@ pub struct Player { /// Local tick and once-per-tick packet state. tick_state: SyncMutex, + /// Vanilla player sleeping counters. + sleep_state: SyncMutex, + + /// Vanilla per-player respawn configuration set by beds and respawn anchors. + respawn_config: SyncMutex>, + /// Player abilities (flight, invulnerability, build permissions, speeds, etc.) pub abilities: SyncMutex, @@ -313,15 +349,44 @@ struct PendingRootVehicleRestore { struct DeathRespawnSpawn { position: DVec3, rotation: (f32, f32), + anchor_deplete_sound_pos: Option, +} + +/// Vanilla per-player respawn configuration. +#[derive(Debug, Clone, PartialEq)] +pub struct PlayerRespawnConfig { + /// Dimension, bed/anchor position, yaw, and pitch used for respawn. + pub respawn_data: RespawnData, + /// Whether respawning is forced even if the spawn block is unavailable. + pub forced: bool, +} + +impl PlayerRespawnConfig { + #[must_use] + const fn new(respawn_data: RespawnData, forced: bool) -> Self { + Self { + respawn_data, + forced, + } + } + + #[must_use] + fn is_same_position(&self, other: Option<&Self>) -> bool { + other.is_some_and(|other| self.respawn_data.global_pos == other.respawn_data.global_pos) + } } struct PlayerRespawnJob { player: Arc, - source_world: Arc, + death_world: Arc, + fallback_world: Arc, + fallback_search: Option, + fallback_rotation: (f32, f32), target_world: Arc, rotation: (f32, f32), kind: RespawnRequestKind, phase: PlayerRespawnJobPhase, + consume_spawn_block: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -331,6 +396,10 @@ enum RespawnRequestKind { } enum PlayerRespawnJobPhase { + LoadingPersonalRespawnBlock { + config: PlayerRespawnConfig, + request: ChunkRequestHandle, + }, Searching(PlayerSpawnSearch), LoadingSpawnChunks { spawn: DeathRespawnSpawn, @@ -341,29 +410,60 @@ enum PlayerRespawnJobPhase { impl PlayerRespawnJob { fn new( player: Arc, - source_world: Arc, - target_world: Arc, - respawn_data: RespawnData, + death_world: Arc, + fallback_world: Arc, + fallback_respawn_data: RespawnData, + personal_respawn: Option<(Arc, PlayerRespawnConfig)>, + consume_spawn_block: bool, kind: RespawnRequestKind, ) -> Result { - let search = PlayerSpawnSearch::new( - &target_world, - respawn_data.pos(), - target_world.default_gamemode, + let fallback_search = PlayerSpawnSearch::new( + &fallback_world, + fallback_respawn_data.pos(), + fallback_world.default_gamemode, )?; + let fallback_rotation = (fallback_respawn_data.yaw, fallback_respawn_data.pitch); + + let (target_world, rotation, phase, fallback_search) = + if let Some((personal_world, config)) = personal_respawn { + let pos = config.respawn_data.pos(); + let request = personal_world.request_player_spawn_chunks(DVec3::new( + f64::from(pos.x()) + 0.5, + f64::from(pos.y()), + f64::from(pos.z()) + 0.5, + )); + ( + personal_world, + (config.respawn_data.yaw, config.respawn_data.pitch), + PlayerRespawnJobPhase::LoadingPersonalRespawnBlock { config, request }, + Some(fallback_search), + ) + } else { + ( + fallback_world.clone(), + fallback_rotation, + PlayerRespawnJobPhase::Searching(fallback_search), + None, + ) + }; + Ok(Self { player, - source_world, + death_world, + fallback_world, + fallback_search, + fallback_rotation, target_world, - rotation: (respawn_data.yaw, respawn_data.pitch), + rotation, + consume_spawn_block, kind, - phase: PlayerRespawnJobPhase::Searching(search), + phase, }) } fn still_valid(&self) -> bool { !self.player.connection.closed() - && Arc::ptr_eq(&self.player.get_world(), &self.source_world) + && Arc::ptr_eq(&self.player.get_world(), &self.death_world) && match self.kind { RespawnRequestKind::Death => { Player::should_process_respawn(self.player.get_health()) @@ -382,6 +482,47 @@ impl ServerJob for PlayerRespawnJob { loop { match &mut self.phase { + PlayerRespawnJobPhase::LoadingPersonalRespawnBlock { config, request } => { + match request.poll() { + ChunkRequestState::Pending { .. } => return JobPoll::Pending, + ChunkRequestState::Cancelled => { + self.player.finish_respawn_request(); + return JobPoll::Finished; + } + ChunkRequestState::Ready => { + if request.ready_chunks().is_none() { + return JobPoll::Pending; + } + + if let Some(spawn) = Self::resolve_personal_respawn( + &self.target_world, + &self.player, + config, + self.consume_spawn_block, + ) { + let request = self + .target_world + .request_player_spawn_chunks(spawn.position); + self.phase = + PlayerRespawnJobPhase::LoadingSpawnChunks { spawn, request }; + continue; + } + + self.player.send_packet(CGameEvent { + event: GameEventType::NoRespawnBlockAvailable, + data: 0.0, + }); + self.player.set_respawn_position(None, false); + let Some(fallback_search) = self.fallback_search.take() else { + self.player.finish_respawn_request(); + return JobPoll::Finished; + }; + self.target_world = self.fallback_world.clone(); + self.rotation = self.fallback_rotation; + self.phase = PlayerRespawnJobPhase::Searching(fallback_search); + } + } + } PlayerRespawnJobPhase::Searching(search) => { match search.poll_with_ready_candidate_budget( &self.target_world, @@ -396,6 +537,7 @@ impl ServerJob for PlayerRespawnJob { let spawn = DeathRespawnSpawn { position, rotation: self.rotation, + anchor_deplete_sound_pos: None, }; let request = self.target_world.request_player_spawn_chunks(position); self.phase = @@ -417,13 +559,13 @@ impl ServerJob for PlayerRespawnJob { match self.kind { RespawnRequestKind::Death => self.player.finish_death_respawn( - &self.source_world, + &self.death_world, &self.target_world, *spawn, ), RespawnRequestKind::EndCredits => { self.player.finish_end_credits_respawn( - &self.source_world, + &self.death_world, &self.target_world, *spawn, ); @@ -442,7 +584,318 @@ impl ServerJob for PlayerRespawnJob { } } +impl PlayerRespawnJob { + fn resolve_personal_respawn( + world: &Arc, + player: &Player, + config: &PlayerRespawnConfig, + consume_spawn_block: bool, + ) -> Option { + let pos = config.respawn_data.pos(); + let state = world.get_block_state(pos); + let block = state.get_block(); + + if RespawnAnchorBlock::can_use_for_respawn(world, pos, state, config.forced) { + let position = RespawnAnchorBlock::find_standup_position(world, player, pos)?; + if RespawnAnchorBlock::should_consume_charge_after_respawn( + config.forced, + consume_spawn_block, + true, + ) { + RespawnAnchorBlock::consume_charge(world, pos, state); + } + + return Some(DeathRespawnSpawn { + position, + rotation: (calculate_respawn_look_at_yaw(position, pos), 0.0), + anchor_deplete_sound_pos: Some(pos), + }); + } + + if block.has_tag(&BlockTag::BEDS) + && Player::bed_rule_value_allows_in_world( + world, + world.dimension_type.bed_rule.can_set_spawn, + ) + { + let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING); + let position = BedBlock::find_standup_position_with_yaw( + world, + player, + facing, + pos, + config.respawn_data.yaw, + )?; + return Some(DeathRespawnSpawn { + position, + rotation: (calculate_respawn_look_at_yaw(position, pos), 0.0), + anchor_deplete_sound_pos: None, + }); + } + + if !config.forced { + return None; + } + + let top_state = world.get_block_state(pos.above()); + Self::resolve_forced_respawn_fallback( + pos, + state, + top_state, + (config.respawn_data.yaw, config.respawn_data.pitch), + ) + } + + fn resolve_forced_respawn_fallback( + pos: BlockPos, + state: BlockStateId, + top_state: BlockStateId, + rotation: (f32, f32), + ) -> Option { + if !state.is_possible_to_respawn_in_this() || !top_state.is_possible_to_respawn_in_this() { + return None; + } + + Some(DeathRespawnSpawn { + position: DVec3::new( + f64::from(pos.x()) + 0.5, + f64::from(pos.y()) + 0.1, + f64::from(pos.z()) + 0.5, + ), + rotation, + anchor_deplete_sound_pos: None, + }) + } +} + +fn calculate_respawn_look_at_yaw(position: DVec3, look_at_block_pos: BlockPos) -> f32 { + let bed_center = DVec3::new( + f64::from(look_at_block_pos.x()) + 0.5, + f64::from(look_at_block_pos.y()), + f64::from(look_at_block_pos.z()) + 0.5, + ); + let look_direction = (bed_center - position).normalize_or_zero(); + wrap_degrees((look_direction.z.atan2(look_direction.x).to_degrees() - 90.0) as f32) +} + impl Player { + fn bed_rule_value_allows_in_world(world: &World, value: BedRuleValue) -> bool { + match value { + BedRuleValue::Always => true, + BedRuleValue::WhenDark => world.is_dark_outside(), + BedRuleValue::Never => false, + } + } + + fn bed_rule_value_allows(&self, value: BedRuleValue) -> bool { + Self::bed_rule_value_allows_in_world(&self.get_world(), value) + } + + fn bed_rule_problem_message(&self) -> Option { + self.get_world() + .dimension_type + .bed_rule + .error_message_key + .as_ref() + .map(|key| { + TranslatedMessage { + key: (*key).into(), + fallback: None, + args: None, + } + .component() + }) + } + + fn bed_sleep_problem(&self) -> BedSleepingProblem { + self.bed_rule_problem_message() + .map_or(BedSleepingProblem::OtherProblem, |message| { + BedSleepingProblem::Message(Box::new(message)) + }) + } + + #[must_use] + fn is_reachable_bed_block_from_position(player_pos: DVec3, bed_block_pos: BlockPos) -> bool { + let bed_center = DVec3::new( + f64::from(bed_block_pos.x()) + 0.5, + f64::from(bed_block_pos.y()), + f64::from(bed_block_pos.z()) + 0.5, + ); + (player_pos.x - bed_center.x).abs() <= BED_INTERACTION_XZ_RANGE + && (player_pos.y - bed_center.y).abs() <= BED_INTERACTION_Y_RANGE + && (player_pos.z - bed_center.z).abs() <= BED_INTERACTION_XZ_RANGE + } + + #[must_use] + fn bed_in_range(&self, pos: BlockPos, direction: steel_utils::Direction) -> bool { + Self::bed_in_range_from_position(self.position(), pos, direction) + } + + #[must_use] + fn bed_in_range_from_position( + player_pos: DVec3, + pos: BlockPos, + direction: steel_utils::Direction, + ) -> bool { + Self::is_reachable_bed_block_from_position(player_pos, pos) + || Self::is_reachable_bed_block_from_position( + player_pos, + direction.opposite().relative(pos), + ) + } + + #[must_use] + fn bed_blocked(&self, pos: BlockPos, direction: steel_utils::Direction) -> bool { + Self::bed_blocked_with_free_at(pos, direction, |pos| self.free_at(pos)) + } + + fn bed_blocked_with_free_at( + pos: BlockPos, + direction: steel_utils::Direction, + mut free_at: impl FnMut(BlockPos) -> bool, + ) -> bool { + let above = pos.above(); + !free_at(above) || !free_at(direction.opposite().relative(above)) + } + + #[must_use] + fn free_at(&self, pos: BlockPos) -> bool { + !self.get_world().get_block_state(pos).is_suffocating() + } + + /// Stops sleeping in bed. + pub(crate) fn stop_sleep_in_bed(&self, forceful_wakeup: bool, update_level_list: bool) { + if self.is_sleeping() { + let packet = CAnimate::new(self.id(), AnimateAction::WakeUp); + self.get_world() + .broadcast_to_entity_trackers(self.id(), packet.clone(), None); + self.send_packet(packet); + } + + self.default_stop_sleeping(); + if update_level_list { + self.get_world().update_sleeping_player_list(); + } + self.set_sleep_counter(if forceful_wakeup { 0 } else { 100 }); + let (yaw, pitch) = self.rotation(); + if let Err(error) = self.teleport(self.position(), yaw, pitch) { + log::warn!( + "Failed to teleport player {} after waking up: {error}", + self.id() + ); + } + self.sync_entity_data(); + } + + /// Starts sleeping in a bed after the admission checks pass. + pub(crate) fn start_sleep_in_bed(&self, pos: BlockPos) -> Result<(), BedSleepingProblem> { + let world = self.get_world(); + let direction = world + .get_block_state(pos) + .get_value(&BlockStateProperties::HORIZONTAL_FACING); + if self.is_sleeping() || !Entity::is_alive(self) { + return Err(BedSleepingProblem::OtherProblem); + } + + let rule = &world.dimension_type.bed_rule; + let can_sleep = self.bed_rule_value_allows(rule.can_sleep); + let can_set_spawn = self.bed_rule_value_allows(rule.can_set_spawn); + if !can_set_spawn && !can_sleep { + return Err(self.bed_sleep_problem()); + } + + if !self.bed_in_range(pos, direction) { + return Err(BedSleepingProblem::Message(Box::new( + TranslatedMessage { + key: "block.minecraft.bed.too_far_away".into(), + fallback: None, + args: None, + } + .component(), + ))); + } + + if self.bed_blocked(pos, direction) { + return Err(BedSleepingProblem::Message(Box::new( + TranslatedMessage { + key: "block.minecraft.bed.obstructed".into(), + fallback: None, + args: None, + } + .component(), + ))); + } + + if can_set_spawn { + self.set_respawn_position( + Some(PlayerRespawnConfig::new( + RespawnData::of(world.key.clone(), pos, self.rotation().0, self.rotation().1), + false, + )), + true, + ); + } + + if !can_sleep { + return Err(self.bed_sleep_problem()); + } + + // TODO: Mirror vanilla Monster::isPreventingPlayerRest in the 8x5x8 + // search box for non-creative players once Steel has a Monster + // capability/class foundation. Do not approximate this with entity + // type tags or MobCategory::Monster; vanilla queries Monster.class. + self.set_sleep_counter(0); + if self.start_sleeping(pos).is_err() { + return Err(BedSleepingProblem::OtherProblem); + } + // TODO: Reset Stats.CUSTOM[TIME_SINCE_REST] once player statistics exist. + self.sync_entity_data(); + if !world.can_sleep_through_nights() { + self.send_overlay_message( + &TranslatedMessage { + key: "sleep.not_possible".into(), + fallback: None, + args: None, + } + .component(), + ); + } + world.update_sleeping_player_list(); + // TODO: Award Stats.SLEEP_IN_BED and trigger the slept-in-bed + // advancement criterion once those foundations exist. + Ok(()) + } + + /// Returns the player's current vanilla respawn configuration. + #[must_use] + pub fn respawn_config(&self) -> Option { + self.respawn_config.lock().clone() + } + + /// Sets the player's vanilla respawn configuration. + pub fn set_respawn_position( + &self, + respawn_config: Option, + show_message: bool, + ) { + let mut current = self.respawn_config.lock(); + if show_message + && respawn_config + .as_ref() + .is_some_and(|respawn_config| !respawn_config.is_same_position(current.as_ref())) + { + self.send_message( + &TranslatedMessage { + key: "block.minecraft.set_spawn".into(), + fallback: None, + args: None, + } + .component(), + ); + } + *current = respawn_config; + } + /// Computes the start (eye position) and end positions for a raytrace. pub fn get_ray_endpoints(&self) -> (DVec3, DVec3) { let pos = self.position(); @@ -541,6 +994,8 @@ impl Player { teleport_state: SyncMutex::new(TeleportState::new()), item_cooldowns: SyncMutex::new(ItemCooldowns::default()), tick_state: SyncMutex::new(PlayerTickState::new()), + sleep_state: SyncMutex::new(PlayerSleepState::new()), + respawn_config: SyncMutex::new(None), abilities: SyncMutex::new(Abilities::default()), block_breaking: SyncMutex::new(BlockBreakingManager::new()), living_base, @@ -578,6 +1033,13 @@ impl Player { self.set_on_ground(false); } + self.tick_sleep_counter(); + if self.is_sleeping() + && !self.bed_rule_value_allows(self.get_world().dimension_type.bed_rule.can_sleep) + { + self.stop_sleep_in_bed(false, true); + } + let tick_position = self.position(); // Vanilla: ServerGamePacketListenerImpl.resetPosition(). @@ -692,14 +1154,12 @@ impl Player { if death_time >= DEATH_DURATION && !self.is_removed() { let world = self.get_world(); let chunk_pos = *self.last_chunk_pos.lock(); - world.broadcast_to_nearby( - chunk_pos, - CEntityEvent { - entity_id: self.id(), - event: EntityStatus::Poof, - }, - None, - ); + let packet = CEntityEvent { + entity_id: self.id(), + event: EntityStatus::Poof, + }; + world.broadcast_to_nearby(chunk_pos, packet.clone(), Some(self.id())); + self.send_packet(packet); world.unregister_player_entity(self); world.entity_tracker().on_player_leave(self.id()); @@ -856,14 +1316,12 @@ impl Player { // Broadcast entity event 3 (death sound) to all nearby players. let chunk_pos = *self.last_chunk_pos.lock(); - world.broadcast_to_nearby( - chunk_pos, - CEntityEvent { - entity_id: self.id(), - event: EntityStatus::Death, - }, - None, - ); + let packet = CEntityEvent { + entity_id: self.id(), + event: EntityStatus::Death, + }; + world.broadcast_to_nearby(chunk_pos, packet.clone(), Some(self.id())); + self.send_packet(packet); let show_death_messages = world.get_game_rule(&SHOW_DEATH_MESSAGES) == GameRuleValue::Bool(true); @@ -924,15 +1382,15 @@ impl Player { } } - /// TODO: personal respawn blocks/anchors and noRespawnBlockAvailable. + /// Schedules a vanilla death respawn for this player. pub fn respawn(&self) { let health = self.get_health(); if !Self::should_process_respawn(health) { return; } - let source_world = self.get_world(); - let Some(player_arc) = source_world.players.get_by_entity_id(self.id()) else { + let death_world = self.get_world(); + let Some(player_arc) = death_world.players.get_by_entity_id(self.id()) else { return; }; if !self.begin_respawn_request() { @@ -947,8 +1405,8 @@ impl Player { ); return; }; - let (target_world, respawn_data) = - match server.respawn_world_and_data_for_domain(source_world.domain()) { + let (fallback_world, fallback_respawn_data) = + match server.respawn_world_and_data_for_domain(death_world.domain()) { Ok(resolved) => resolved, Err(error) => { self.finish_respawn_request(); @@ -959,12 +1417,24 @@ impl Player { return; } }; + let personal_respawn = self.respawn_config().and_then(|config| { + server + .worlds + .get(config.respawn_data.dimension()) + .filter(|world| world.domain() == death_world.domain()) + .cloned() + .map(|world| (world, config)) + }); + let consume_spawn_block = + death_world.get_game_rule(&KEEP_INVENTORY) != GameRuleValue::Bool(true); match PlayerRespawnJob::new( player_arc, - source_world, - target_world, - respawn_data, + death_world, + fallback_world, + fallback_respawn_data, + personal_respawn, + consume_spawn_block, RespawnRequestKind::Death, ) { Ok(job) => server.jobs.spawn(job), @@ -980,14 +1450,14 @@ impl Player { fn finish_death_respawn( self: &Arc, - source_world: &Arc, + death_world: &Arc, target_world: &Arc, spawn: DeathRespawnSpawn, ) { self.finish_respawn_request(); if self.connection.closed() - || !Arc::ptr_eq(&self.get_world(), source_world) + || !Arc::ptr_eq(&self.get_world(), death_world) || !Self::should_process_respawn(self.get_health()) { return; @@ -996,34 +1466,203 @@ impl Player { self.reset_state_for_death_respawn(); let was_removed = self.base.clear_removed(); - // TODO: personal respawn blocks/anchors and NO_RESPAWN_BLOCK_AVAILABLE. + if !was_removed && Arc::ptr_eq(death_world, target_world) { + death_world.unregister_player_entity(self); + } - if !was_removed && Arc::ptr_eq(source_world, target_world) { - source_world.unregister_player_entity(self); + // Handle XP loss on death before sending respawn packet + { + let mut experience = self.experience.lock(); + let is_spectator = self.game_mode() == GameType::Spectator; + if death_world.get_game_rule(&KEEP_INVENTORY) != GameRuleValue::Bool(true) + && !is_spectator + { + let reward = experience.death_xp_reward(); + if reward > 0 { + ExperienceOrbEntity::award(death_world, self.position(), reward); + } + } + if target_world.get_game_rule(&KEEP_INVENTORY) != GameRuleValue::Bool(true) + && !is_spectator + { + experience.set_total_points(0); + } } - // Shared reset (clears transient state, sends CRespawn) self.reset(target_world.clone(), ResetReason::Respawn); + if let Err(error) = self.teleport(spawn.position, spawn.rotation.0, spawn.rotation.1) { + panic!( + "failed to synchronize player {} respawn position: {error}", + self.id() + ); + } + self.reset_flying_ticks(); + + if let Some(server) = self.server.upgrade() { + match server.respawn_data_for_domain(target_world.domain()) { + Ok(respawn_data) => { + self.send_packet(CSetDefaultSpawnPosition { + global_pos: respawn_data.global_pos, + yaw: respawn_data.yaw, + pitch: respawn_data.pitch, + }); + } + Err(error) => { + log::error!( + "Failed to send default spawn position to player {}: {error}", + self.gameprofile.name + ); + } + } + } self.send_difficulty(); - // Handle XP loss on death { let mut experience = self.experience.lock(); - if target_world.get_game_rule(&KEEP_INVENTORY) != GameRuleValue::Bool(true) - && self.game_mode() != GameType::Spectator - { - // TODO: drop XP orbs (min(level * 7, 100)) - experience.set_total_points(0); + self.send_packet(CSetExperience { + progress: experience.progress() as f32, + level: experience.level(), + total_experience: experience.total_points(), + }); + experience.dirty = false; + } + + for effect in self.living_base.active_mob_effects() { + self.send_mob_effect_sync_packet( + MobEffectSyncChange::Update { + effect, + blend_for_self: false, + } + .packet(self.id(), true), + ); + } + + self.send_respawn_level_info(target_world); + + // TODO: Set permissions level to match the player actual operator when 4lve merges command_refacotr + self.send_packet(CEntityEvent { + entity_id: self.id(), + event: EntityStatus::PermissionLevelOwners, + }); + + if !self.add_to_respawned_world_after_level_info(target_world) { + return; + } + self.sync_entity_data(); + self.send_inventory_to_remote(); + self.send_current_health(); + + if let Some(pos) = spawn.anchor_deplete_sound_pos + && target_world.get_block_state(pos).get_block() == &vanilla_blocks::RESPAWN_ANCHOR + { + self.send_block_sound(&sound_events::BLOCK_RESPAWN_ANCHOR_DEPLETE, pos, 1.0, 1.0); + } + } + + fn send_respawn_level_info(&self, world: &Arc) { + self.send_packet(world.initialize_border_packet()); + + { + let level_data = world.level_data.read(); + let game_time = level_data.game_time(); + let day_time = level_data.day_time(); + drop(level_data); + + let advance_time = world + .get_game_rule(&ADVANCE_TIME) + .as_bool() + .expect("gamerule advance_time should always be a bool."); + let rate = if advance_time { 1.0 } else { 0.0 }; + self.send_packet(CSetTime::new(game_time, day_time, 0.0, rate)); + } + + if let Some(server) = self.server.upgrade() { + match server.respawn_data_for_domain(world.domain()) { + Ok(respawn_data) => { + self.send_packet(CSetDefaultSpawnPosition { + global_pos: respawn_data.global_pos, + yaw: respawn_data.yaw, + pitch: respawn_data.pitch, + }); + } + Err(error) => { + log::error!( + "Failed to send level-info spawn position to player {}: {error}", + self.gameprofile.name + ); + } } - // Re-send XP to client after respawn regardless of keepInventory - experience.dirty = true; } - // TODO: send mob effect packets once effects are implemented + if world.can_have_weather() && world.is_raining() { + let (rain_level, thunder_level) = { + let weather = world.weather.lock(); + (weather.rain_level, weather.thunder_level) + }; - // Shared spawn (teleport, abilities, weather, time, chunk tracking reset) - let _ = self.spawn(spawn.position, spawn.rotation, ResetReason::Respawn); + self.send_packet(CGameEvent { + event: GameEventType::StartRaining, + data: 0.0, + }); + self.send_packet(CGameEvent { + event: GameEventType::RainLevelChange, + data: rain_level, + }); + self.send_packet(CGameEvent { + event: GameEventType::ThunderLevelChange, + data: thunder_level, + }); + } + + self.send_packet(CGameEvent { + event: GameEventType::LevelChunksLoadStart, + data: 0.0, + }); + self.server().send_ticking_state_to_player(self); + } + + fn add_to_respawned_world_after_level_info(self: &Arc, world: &Arc) -> bool { + if world.players.get_by_entity_id(self.id()).is_none() { + if !world.players.insert(self.clone()) { + self.connection.close(); + return false; + } + } else { + world.player_area_map.remove_by_entity_id(self.id()); + world.chunk_map.remove_player(self); + world.entity_tracker().on_player_leave(self.id()); + } + + world.register_respawned_player_entity(self); + world.update_sleeping_player_list(); + true + } + + fn send_current_health(&self) { + let health = self.get_health(); + let (food, saturation) = { + let food_data = self.food_data.lock(); + (food_data.food_level, food_data.saturation_level) + }; + self.send_packet(CSetHealth { + health, + food, + food_saturation: saturation, + }); + self.health_sync + .lock() + .record_sent(health, food, saturation == 0.0); + } + + fn send_block_sound(&self, sound: SoundEventRef, pos: BlockPos, volume: f32, pitch: f32) { + self.send_packet(CSound::block_sound( + sound, + pos, + volume, + pitch, + rand::random::(), + )); } fn finish_end_credits_respawn( @@ -1194,8 +1833,10 @@ impl Player { match PlayerRespawnJob::new( Arc::clone(self), source_world, - target_world, + target_world.clone(), respawn_data, + None, + false, RespawnRequestKind::EndCredits, ) { Ok(job) => server.jobs.spawn(job), @@ -1434,6 +2075,28 @@ impl Player { self.tick_state.lock().set_take_xp_delay(delay); } + /// Returns vanilla `Player.sleepCounter`. + #[must_use] + pub fn sleep_counter(&self) -> i32 { + self.sleep_state.lock().sleep_counter() + } + + /// Returns whether this player has slept long enough for vanilla night skip. + #[must_use] + pub fn is_sleeping_long_enough(&self) -> bool { + self.is_sleeping() && self.sleep_counter() >= 100 + } + + fn set_sleep_counter(&self, sleep_counter: i32) { + self.sleep_state.lock().set_sleep_counter(sleep_counter); + } + + fn tick_sleep_counter(&self) { + self.sleep_state + .lock() + .tick_sleep_counter(self.is_sleeping()); + } + /// Gives raw experience points to this player. pub(crate) fn give_experience_points(&self, points: i32) { self.experience.lock().add_points(points); @@ -2228,6 +2891,10 @@ impl Entity for Player { } impl LivingEntity for Player { + fn living_synced_data(&self) -> Option<&dyn LivingEntitySyncedData> { + Some(&self.entity_data) + } + fn get_health(&self) -> f32 { *self.entity_data.lock().living_entity().health.get() } @@ -2327,6 +2994,10 @@ impl LivingEntity for Player { self.default_is_immobile() || self.is_sleeping() } + fn stop_sleeping(&self) { + self.stop_sleep_in_bed(true, true); + } + fn jump_from_ground(&self) { self.default_jump_from_ground(); // TODO: Award Stats.JUMP once player statistics exist. @@ -2413,14 +3084,17 @@ impl TextResolutor for Player { #[cfg(test)] mod tests { + use glam::DVec3; use steel_registry::{ - test_support::init_test_registry, vanilla_damage_types, vanilla_game_rules, + test_support::init_test_registry, vanilla_blocks, vanilla_damage_types, vanilla_game_rules, }; use steel_utils::types::GameType; + use steel_utils::{BlockPos, Direction}; + use crate::behavior::{BlockStateBehaviorExt as _, init_behaviors}; use crate::entity::damage::DamageSource; - use super::{Player, ResetReason, nullable_game_mode_id}; + use super::{Player, PlayerRespawnJob, ResetReason, nullable_game_mode_id}; #[test] fn respawn_request_is_allowed_after_dead_reconnect() { @@ -2499,4 +3173,126 @@ mod tests { assert_eq!(nullable_game_mode_id(None), -1); assert_eq!(nullable_game_mode_id(Some(GameType::Creative)), 1); } + + #[test] + fn sleep_admission_blocks_too_far_away_player() { + let bed_pos = BlockPos::new(0, 64, 0); + let direction = Direction::South; + + assert!(Player::bed_in_range_from_position( + DVec3::new(3.5, 64.0, 0.5), + bed_pos, + direction, + )); + assert!(!Player::bed_in_range_from_position( + DVec3::new(4.0, 64.0, 0.5), + bed_pos, + direction, + )); + } + + #[test] + fn sleep_admission_blocks_obstructed_bed() { + let bed_pos = BlockPos::new(0, 64, 0); + let direction = Direction::South; + let above_head = bed_pos.above(); + let above_foot = direction.opposite().relative(above_head); + + assert!(!Player::bed_blocked_with_free_at( + bed_pos, + direction, + |_| true, + )); + assert!(Player::bed_blocked_with_free_at( + bed_pos, + direction, + |pos| pos != above_head, + )); + assert!(Player::bed_blocked_with_free_at( + bed_pos, + direction, + |pos| pos != above_foot, + )); + } + + #[test] + fn forced_respawn_allows_vanilla_non_solid_non_liquid_blocks() { + init_test_registry(); + init_behaviors(); + + assert!( + vanilla_blocks::AIR + .default_state() + .is_possible_to_respawn_in_this() + ); + assert!( + vanilla_blocks::OAK_SIGN + .default_state() + .is_possible_to_respawn_in_this() + ); + assert!( + vanilla_blocks::WHITE_BANNER + .default_state() + .is_possible_to_respawn_in_this() + ); + assert!( + vanilla_blocks::OAK_PRESSURE_PLATE + .default_state() + .is_possible_to_respawn_in_this() + ); + } + + #[test] + fn forced_respawn_rejects_vanilla_solid_or_liquid_blocks() { + init_test_registry(); + init_behaviors(); + + assert!( + !vanilla_blocks::STONE + .default_state() + .is_possible_to_respawn_in_this() + ); + assert!( + !vanilla_blocks::WATER + .default_state() + .is_possible_to_respawn_in_this() + ); + } + + #[test] + fn forced_respawn_fallback_uses_block_respawn_semantics() { + init_test_registry(); + init_behaviors(); + + let pos = BlockPos::new(1, 64, 2); + let rotation = (45.0, 10.0); + let spawn = PlayerRespawnJob::resolve_forced_respawn_fallback( + pos, + vanilla_blocks::OAK_SIGN.default_state(), + vanilla_blocks::AIR.default_state(), + rotation, + ) + .expect("signs override Block.isPossibleToRespawnInThis in vanilla"); + + assert_eq!(spawn.position, DVec3::new(1.5, 64.1, 2.5)); + assert_eq!(spawn.rotation, rotation); + assert!( + PlayerRespawnJob::resolve_forced_respawn_fallback( + pos, + vanilla_blocks::STONE.default_state(), + vanilla_blocks::AIR.default_state(), + rotation, + ) + .is_none() + ); + assert!( + PlayerRespawnJob::resolve_forced_respawn_fallback( + pos, + vanilla_blocks::AIR.default_state(), + vanilla_blocks::WATER.default_state(), + rotation, + ) + .is_none() + ); + } } diff --git a/steel-core/src/player/movement.rs b/steel-core/src/player/movement.rs index a9a343eb2e2d..76acff973db2 100644 --- a/steel-core/src/player/movement.rs +++ b/steel-core/src/player/movement.rs @@ -910,15 +910,6 @@ impl Player { PlayerCommandAction::LeaveBed => { if self.is_sleeping() { self.stop_sleeping(); - // TODO: Full bed wake-up logic: - // - set bed block OCCUPIED property to false - // - compute stand-up position via BedBlock::findStandUpPosition - // - teleport player + set rotation toward bed - // - set pose to Standing, clear sleeping pos entity data - // - update server sleeping player list (for sleep-skip) - // - set sleepCounter = 100 - // - set awaiting_position_from_client - // Blocked on: bed block properties, sleeping pos entity data } } PlayerCommandAction::StartRidingJump diff --git a/steel-core/src/player/player_data.rs b/steel-core/src/player/player_data.rs index 9657604aa431..80e71b33b5a4 100644 --- a/steel-core/src/player/player_data.rs +++ b/steel-core/src/player/player_data.rs @@ -12,11 +12,11 @@ use crate::{ inventory::container::Container, }; -use super::{Player, abilities::Abilities}; +use super::{Player, PlayerRespawnConfig, abilities::Abilities}; /// Current data version for player saves. /// Increment when making breaking changes to the format. -pub const PLAYER_DATA_VERSION: i32 = 5; +pub const PLAYER_DATA_VERSION: i32 = 6; /// Persistent player data saved by Steel's storage backend. /// @@ -110,6 +110,9 @@ pub struct PersistentPlayerData { /// Vanilla one-player root vehicle tree stored with the player instead of chunk data. pub root_vehicle: Option, + /// Vanilla per-player respawn configuration set by beds and respawn anchors. + pub respawn_config: Option, + /// Vanilla in-flight ender pearls stored with the player (`ServerPlayer.enderPearls`). pub ender_pearls: Vec, } @@ -242,6 +245,8 @@ impl PersistentPlayerData { score, seen_credits: player.has_seen_credits(), root_vehicle, + respawn_config: player.respawn_config(), + ender_pearls, } } @@ -371,6 +376,7 @@ impl PersistentPlayerData { self.has_visual_fire, )); player.sync_base_fire_freeze_entity_data(); + player.set_respawn_position(self.respawn_config.clone(), false); // Health player.set_health(self.health); diff --git a/steel-core/src/player/player_data_storage.rs b/steel-core/src/player/player_data_storage.rs index 77d0106b07c2..5946ff35df04 100644 --- a/steel-core/src/player/player_data_storage.rs +++ b/steel-core/src/player/player_data_storage.rs @@ -12,20 +12,22 @@ use tokio::{fs, io}; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite}; +use super::PlayerRespawnConfig; use super::player_data::{ PLAYER_DATA_VERSION, PersistentAbilities, PersistentEnderPearl, PersistentPlayerData, PersistentRootVehicle, PersistentSlot, }; use crate::chunk_saver::PersistentEntity; use crate::config::StorageSelection; +use crate::level_data::RespawnData; use crate::player::Player; use steel_registry::item_stack::ItemStack; -use steel_utils::Identifier; use steel_utils::locks::{AsyncMutex, SyncMutex}; +use steel_utils::{BlockPos, Identifier}; const PLAYER_MAGIC: [u8; 4] = *b"STLP"; const GLOBAL_MAGIC: [u8; 4] = *b"STLG"; -const PLAYER_STORAGE_VERSION: u16 = 7; +const PLAYER_STORAGE_VERSION: u16 = 8; const GLOBAL_STORAGE_VERSION: u16 = 1; const GLOBAL_PLAYER_DATA_VERSION: i32 = 1; @@ -80,6 +82,7 @@ struct PlayerDataFile { score: i32, seen_credits: bool, root_vehicle: Option, + respawn_config: Option, ender_pearls: Vec, } @@ -89,6 +92,15 @@ struct RootVehicleFile { entity: PersistentEntity, } +#[derive(SchemaWrite, SchemaRead)] +struct RespawnConfigFile { + dimension: String, + pos: [i32; 3], + yaw: f32, + pitch: f32, + forced: bool, +} + #[derive(SchemaWrite, SchemaRead)] struct EnderPearlFile { world: String, @@ -376,6 +388,10 @@ impl PlayerDataFile { attach: root_vehicle.attach, entity: root_vehicle.entity, }), + respawn_config: data + .respawn_config + .clone() + .map(RespawnConfigFile::from_runtime), ender_pearls: data .ender_pearls .iter() @@ -446,6 +462,10 @@ impl PlayerDataFile { attach: root_vehicle.attach, entity: root_vehicle.entity, }), + respawn_config: self + .respawn_config + .map(RespawnConfigFile::into_runtime) + .transpose()?, ender_pearls: self .ender_pearls .into_iter() @@ -458,6 +478,36 @@ impl PlayerDataFile { } } +impl RespawnConfigFile { + fn from_runtime(config: PlayerRespawnConfig) -> Self { + let pos = config.respawn_data.pos(); + Self { + dimension: config.respawn_data.dimension().to_string(), + pos: [pos.x(), pos.y(), pos.z()], + yaw: config.respawn_data.yaw, + pitch: config.respawn_data.pitch, + forced: config.forced, + } + } + + fn into_runtime(self) -> io::Result { + Ok(PlayerRespawnConfig { + respawn_data: RespawnData::of( + self.dimension.parse().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid respawn dimension: {error}"), + ) + })?, + BlockPos::new(self.pos[0], self.pos[1], self.pos[2]), + self.yaw, + self.pitch, + ), + forced: self.forced, + }) + } +} + fn item_to_nbt_bytes(item: &ItemStack) -> io::Result> { let NbtTag::Compound(compound) = item.clone().to_nbt_tag() else { return Err(io::Error::new( @@ -595,6 +645,7 @@ mod tests { score: 9, seen_credits: true, root_vehicle: None, + respawn_config: None, ender_pearls: Vec::new(), } } @@ -707,8 +758,15 @@ mod tests { } #[test] - fn player_file_roundtrip_preserves_ender_pearls() { + fn player_file_roundtrip_preserves_respawn_config() { let mut file = sample_player_file(PLAYER_DATA_VERSION); + file.respawn_config = Some(RespawnConfigFile { + dimension: "minecraft:overworld".to_owned(), + pos: [10, 64, -3], + yaw: 181.0, + pitch: -120.0, + forced: false, + }); file.ender_pearls = vec![ EnderPearlFile { world: "minecraft:overworld".to_owned(), @@ -726,6 +784,24 @@ mod tests { .into_persistent() .expect("player file should convert"); + let Some(respawn_config) = persistent.respawn_config else { + panic!("respawn config should survive roundtrip"); + }; + assert_eq!( + respawn_config.respawn_data.dimension(), + &Identifier::vanilla_static("overworld") + ); + assert_eq!(respawn_config.respawn_data.pos(), BlockPos::new(10, 64, -3)); + assert_eq!( + respawn_config.respawn_data.yaw.to_bits(), + (-179.0_f32).to_bits() + ); + assert_eq!( + respawn_config.respawn_data.pitch.to_bits(), + (-90.0_f32).to_bits() + ); + assert!(!respawn_config.forced); + assert_eq!(persistent.ender_pearls.len(), 2); assert_eq!(persistent.ender_pearls[0].world, "minecraft:overworld"); assert_eq!(persistent.ender_pearls[1].world, "minecraft:the_nether"); diff --git a/steel-core/src/player/sleep_state.rs b/steel-core/src/player/sleep_state.rs new file mode 100644 index 000000000000..0a9ab910af7d --- /dev/null +++ b/steel-core/src/player/sleep_state.rs @@ -0,0 +1,57 @@ +#[derive(Debug, Clone, Copy)] +pub(super) struct PlayerSleepState { + sleep_counter: i32, +} + +impl PlayerSleepState { + #[must_use] + pub(super) const fn new() -> Self { + Self { sleep_counter: 0 } + } + + #[must_use] + pub(super) const fn sleep_counter(self) -> i32 { + self.sleep_counter + } + + pub(super) const fn set_sleep_counter(&mut self, sleep_counter: i32) { + self.sleep_counter = sleep_counter; + } + + pub(super) const fn tick_sleep_counter(&mut self, is_sleeping: bool) { + if is_sleeping { + self.sleep_counter += 1; + if self.sleep_counter > 100 { + self.sleep_counter = 100; + } + } else if self.sleep_counter > 0 { + self.sleep_counter += 1; + if self.sleep_counter >= 110 { + self.sleep_counter = 0; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::PlayerSleepState; + + #[test] + fn sleep_counter_matches_vanilla_wake_animation_window() { + let mut state = PlayerSleepState::new(); + + state.set_sleep_counter(99); + state.tick_sleep_counter(true); + state.tick_sleep_counter(true); + assert_eq!(state.sleep_counter(), 100); + + state.tick_sleep_counter(false); + assert_eq!(state.sleep_counter(), 101); + + for _ in 0..9 { + state.tick_sleep_counter(false); + } + assert_eq!(state.sleep_counter(), 0); + } +} diff --git a/steel-core/src/world/environment.rs b/steel-core/src/world/environment.rs index f4f14204119c..b5a70564ba2d 100644 --- a/steel-core/src/world/environment.rs +++ b/steel-core/src/world/environment.rs @@ -204,6 +204,7 @@ mod tests { fn overworld_sky_light_uses_generated_day_timeline() { init_test_registry(); + assert_f32_close(sky_light_level(&OVERWORLD, 1000, 0.0, 0.0, true), 15.0); assert_f32_close(sky_light_level(&OVERWORLD, 6000, 0.0, 0.0, true), 15.0); assert_f32_close(sky_light_level(&OVERWORLD, 18000, 0.0, 0.0, true), 4.0); } diff --git a/steel-core/src/world/mod.rs b/steel-core/src/world/mod.rs index 0a0e4ef30637..696e4d02df0d 100644 --- a/steel-core/src/world/mod.rs +++ b/steel-core/src/world/mod.rs @@ -20,6 +20,7 @@ use crate::portal::WorldChangeRequest; use crate::saved_data::{SavedDataManager, names as saved_data_names}; use crate::world::game_event_context::GameEventContext; use crate::world::game_event_listener::{GameEventListenerStorage, SharedGameEventListener}; +use crate::world::sleep_status::SleepStatus; use crate::{chunk::chunk_map::ChunkMapGameTickTimings, world::weather::Weather}; use glam::DVec3; @@ -54,7 +55,8 @@ use steel_registry::loot_table::LootContext; use steel_registry::sound_event::SoundEventRef; use steel_registry::vanilla_block_tags::BlockTag; use steel_registry::vanilla_game_rules::{ - BLOCK_DROPS, GLOBAL_SOUND_EVENTS, PLAYERS_NETHER_PORTAL_DEFAULT_DELAY, RANDOM_TICK_SPEED, + BLOCK_DROPS, GLOBAL_SOUND_EVENTS, PLAYERS_NETHER_PORTAL_DEFAULT_DELAY, + PLAYERS_SLEEPING_PERCENTAGE, RANDOM_TICK_SPEED, }; use steel_registry::{REGISTRY, RegistryEntry, RegistryExt, dimension_type::DimensionTypeRef}; use steel_registry::{block_entity_type::BlockEntityTypeRef, vanilla_dimension_types}; @@ -69,6 +71,7 @@ use steel_utils::{ random::{Random as _, RandomSource, legacy_random::LegacyRandom}, }; use steel_worldgen::{biomes::obfuscate_biome_seed, noise::PerlinSimplexNoise}; +use text_components::{TextComponent, translation::TranslatedMessage}; /// Controls how a block position is treated during a raytrace traversal. /// @@ -100,7 +103,7 @@ use crate::{ entity::{ AddEntityError, Entity, EntityChangeSenders, EntityChunkCallback, EntityLifecycleChanges, EntityMovementSyncPacket, EntityOwnership, EntityTracker, EntityVisibility, - InactiveEntityCallback, MobEffectSyncPacket, RemovalReason, SharedEntity, + InactiveEntityCallback, LivingEntity, MobEffectSyncPacket, RemovalReason, SharedEntity, WorldEntityManager, entities::ItemEntity, }, fluid::{FluidStateExt as _, fluid_state_to_block}, @@ -196,6 +199,7 @@ mod level_reader; mod player_area_map; mod player_map; pub(crate) mod player_spawn_finder; +mod sleep_status; pub mod tick_scheduler; mod weather; mod world_entities; @@ -395,6 +399,8 @@ pub struct World { saved_data: SavedDataManager, /// Runtime world border state. world_border: SyncMutex, + /// Vanilla sleeping player counts for night-skip checks. + sleep_status: SyncMutex, /// Server view distance (maximum chunk radius). pub view_distance: u8, /// Server simulation distance. @@ -522,6 +528,7 @@ impl World { level_data: SyncRwLock::new(level_data), saved_data, world_border: SyncMutex::new(world_border), + sleep_status: SyncMutex::new(SleepStatus::default()), view_distance, simulation_distance, compression, @@ -1634,6 +1641,139 @@ impl World { .set(rule, value, ®ISTRY.game_rules) } + /// Returns whether this world can skip night. + #[must_use] + pub fn can_sleep_through_nights(&self) -> bool { + self.players_sleeping_percentage() <= 100 + } + + fn players_sleeping_percentage(&self) -> i32 { + self.get_game_rule(&PLAYERS_SLEEPING_PERCENTAGE) + .as_int() + .unwrap_or(100) + } + + fn sleeping_players(&self) -> Vec> { + let mut players = Vec::new(); + self.players.iter_players(|_, player| { + players.push(player.clone()); + true + }); + players + } + + /// Updates vanilla sleeping player counts and broadcasts the sleep status overlay. + pub fn update_sleeping_player_list(&self) { + let players = self.sleeping_players(); + let mut sleep_status = self.sleep_status.lock(); + let changed = sleep_status.update(players.iter().map(Arc::as_ref)); + if changed && !players.is_empty() { + self.announce_sleep_status(*sleep_status); + } + } + + fn announce_sleep_status(&self, sleep_status: SleepStatus) { + if !self.can_sleep_through_nights() { + return; + } + + let percentage = self.players_sleeping_percentage(); + let message = if sleep_status.are_enough_sleeping(percentage) { + TranslatedMessage { + key: "sleep.skipping_night".into(), + fallback: None, + args: None, + } + .component() + } else { + TranslatedMessage { + key: "sleep.players_sleeping".into(), + fallback: None, + args: Some( + vec![ + TextComponent::from(sleep_status.amount_sleeping().to_string()), + TextComponent::from(sleep_status.sleepers_needed(percentage).to_string()), + ] + .into(), + ), + } + .component() + }; + + self.broadcast_to_all_with(|player| CSystemChat::new(&message, true, player)); + } + + fn tick_sleeping_players(&self) { + if self.players.is_empty() { + return; + } + + let percentage = self.players_sleeping_percentage(); + let sleepers_needed = { + let sleep_status = self.sleep_status.lock(); + if !sleep_status.are_enough_sleeping(percentage) { + return; + } + sleep_status.sleepers_needed(percentage) + }; + + let mut deep_sleepers = 0; + + self.players.iter_players(|_, player| { + if !player.is_spectator() && player.is_sleeping_long_enough() { + deep_sleepers += 1; + } + + deep_sleepers < sleepers_needed + }); + + if deep_sleepers < sleepers_needed { + return; + } + + if self.get_game_rule(&ADVANCE_TIME).as_bool().unwrap_or(true) { + self.move_day_time_to_next_morning(); + } + + self.wake_up_all_players(); + if self + .get_game_rule(&ADVANCE_WEATHER) + .as_bool() + .unwrap_or(true) + && self.is_raining() + { + self.reset_weather_cycle(); + } + } + + fn move_day_time_to_next_morning(&self) { + let (game_time, day_time) = { + let mut level_data = self.level_data.write(); + let current_day_time = level_data.day_time(); + let next_morning = next_morning_day_time(current_day_time); + level_data.set_day_time(next_morning); + (level_data.game_time(), next_morning) + }; + self.broadcast_to_all(CSetTime::new(game_time, day_time, 0.0, 1.0)); + } + + fn wake_up_all_players(&self) { + self.sleep_status.lock().remove_all_sleepers(); + for player in self.sleeping_players() { + if player.is_sleeping() { + player.stop_sleep_in_bed(false, false); + } + } + } + + fn reset_weather_cycle(&self) { + let mut level_data = self.level_data.write(); + level_data.set_rain_time(0); + level_data.set_raining(false); + level_data.set_thunder_time(0); + level_data.set_thundering(false); + } + /// Gets the world seed. #[must_use] pub fn seed(&self) -> i64 { @@ -1933,6 +2073,27 @@ impl World { } } + /// Updates all neighboring shapes around `pos`. + pub fn update_neighbor_shapes_at( + self: &Arc, + state: BlockStateId, + pos: BlockPos, + flags: UpdateFlags, + update_limit: i32, + ) { + for direction in Direction::UPDATE_SHAPE_ORDER { + let neighbor_pos = pos.relative(direction); + self.neighbor_shape_changed( + direction.opposite(), + neighbor_pos, + pos, + state, + flags, + update_limit, + ); + } + } + /// Updates comparators that can read analog output from `pos`. /// /// Mirrors vanilla `Level.updateNeighbourForOutputSignal`. @@ -2119,6 +2280,9 @@ impl World { if runs_normally { self.tick_world_border(); self.tick_weather(); + } + self.tick_sleeping_players(); + if runs_normally { self.tick_time(); } @@ -4714,6 +4878,11 @@ fn nearest_player_distance_in_range( max_distance < 0.0 || distance_sqr < max_distance_sqr } +const fn next_morning_day_time(current_day_time: i64) -> i64 { + let advanced_time = current_day_time + 24000; + advanced_time - advanced_time.rem_euclid(24000) +} + impl LevelReader for World { fn get_block_state(&self, pos: BlockPos) -> BlockStateId { Self::get_block_state(self, pos) @@ -4945,6 +5114,13 @@ mod tests { assert!(nearest_player_distance_in_range(1_000_000.0, -1.0, 1.0)); } + #[test] + fn sleep_skip_day_time_matches_vanilla_next_morning() { + assert_eq!(next_morning_day_time(12542), 24000); + assert_eq!(next_morning_day_time(23999), 24000); + assert_eq!(next_morning_day_time(24000), 48000); + } + #[test] fn spawnable_bounds_match_vanilla_teleport_command_bounds() { assert!(World::is_in_spawnable_bounds(BlockPos::new(0, 320, 0))); diff --git a/steel-core/src/world/sleep_status.rs b/steel-core/src/world/sleep_status.rs new file mode 100644 index 000000000000..2393a94f35f8 --- /dev/null +++ b/steel-core/src/world/sleep_status.rs @@ -0,0 +1,73 @@ +use crate::{ + entity::{Entity, LivingEntity}, + player::Player, +}; + +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct SleepStatus { + active_players: i32, + sleeping_players: i32, +} + +impl SleepStatus { + #[must_use] + pub(super) fn are_enough_sleeping(self, sleep_percentage_needed: i32) -> bool { + self.sleeping_players >= self.sleepers_needed(sleep_percentage_needed) + } + + #[must_use] + pub(super) fn sleepers_needed(self, sleep_percentage_needed: i32) -> i32 { + let sleepers = + ((self.active_players as f32 * sleep_percentage_needed as f32) / 100.0).ceil() as i32; + sleepers.max(1) + } + + pub(super) const fn remove_all_sleepers(&mut self) { + self.sleeping_players = 0; + } + + #[must_use] + pub(super) const fn amount_sleeping(self) -> i32 { + self.sleeping_players + } + + pub(super) fn update<'a>(&mut self, players: impl IntoIterator) -> bool { + let old_active_players = self.active_players; + let old_sleeping_players = self.sleeping_players; + self.active_players = 0; + self.sleeping_players = 0; + + for player in players { + if player.is_spectator() { + continue; + } + self.active_players += 1; + if player.is_sleeping() { + self.sleeping_players += 1; + } + } + + (old_sleeping_players > 0 || self.sleeping_players > 0) + && (old_active_players != self.active_players + || old_sleeping_players != self.sleeping_players) + } +} + +#[cfg(test)] +mod tests { + use super::SleepStatus; + + #[test] + fn sleepers_needed_matches_vanilla_percentage_rule() { + let status = SleepStatus { + active_players: 3, + sleeping_players: 0, + }; + + assert_eq!(status.sleepers_needed(0), 1); + assert_eq!(status.sleepers_needed(1), 1); + assert_eq!(status.sleepers_needed(50), 2); + assert_eq!(status.sleepers_needed(100), 3); + assert_eq!(status.sleepers_needed(101), 4); + } +} diff --git a/steel-core/src/world/world_entities.rs b/steel-core/src/world/world_entities.rs index e20f41601380..478d577b3722 100644 --- a/steel-core/src/world/world_entities.rs +++ b/steel-core/src/world/world_entities.rs @@ -91,6 +91,7 @@ impl World { } self.register_respawned_player_entity(&player); + self.update_sleeping_player_list(); player.send_packet(CGameEvent { event: GameEventType::LevelChunksLoadStart, data: 0.0, @@ -119,6 +120,7 @@ impl World { self.player_area_map.on_player_leave(&player); self.chunk_map.remove_player(&player); + self.update_sleeping_player_list(); let start = Instant::now(); @@ -169,6 +171,7 @@ impl World { self.player_area_map.on_player_leave(&player); // Note: no CRemovePlayerInfo — player stays in the global tab list self.chunk_map.remove_player(&player); + self.update_sleeping_player_list(); } /// Removes a player during a domain switch after the caller has saved @@ -185,6 +188,7 @@ impl World { self.entity_tracker().on_player_leave(entity_id); self.player_area_map.on_player_leave(&player); self.chunk_map.remove_player(&player); + self.update_sleeping_player_list(); } /// Adds a player to the world. @@ -201,6 +205,7 @@ impl World { self.register_player_entity(&player); self.chunk_map.update_player_status(&player); + self.update_sleeping_player_list(); player.send_packet(CGameEvent { event: GameEventType::LevelChunksLoadStart, diff --git a/steel-core/src/worldgen/template.rs b/steel-core/src/worldgen/template.rs index 268290dda124..01eed8529451 100644 --- a/steel-core/src/worldgen/template.rs +++ b/steel-core/src/worldgen/template.rs @@ -35,6 +35,7 @@ use steel_utils::random::{PositionalRandom, Random, RandomSource}; use steel_utils::value_providers::IntProvider; use steel_utils::{ BlockPos, BlockStateId, BoundingBox, Direction, Identifier, Rotation, types::UpdateFlags, + wrap_degrees, }; use text_components::TextComponent; use uuid::Uuid; @@ -578,7 +579,7 @@ impl StructureTemplate { mirror: StructureMirror, rotation: Rotation, ) -> (f32, f32) { - let yaw = Self::wrap_degrees(yaw); + let yaw = wrap_degrees(yaw); let rotated = match rotation { Rotation::Clockwise180 => yaw + 180.0, Rotation::CounterClockwise90 => yaw + 270.0, @@ -636,17 +637,6 @@ impl StructureTemplate { } } - fn wrap_degrees(mut degrees: f32) -> f32 { - degrees %= 360.0; - if degrees >= 180.0 { - degrees -= 360.0; - } - if degrees < -180.0 { - degrees += 360.0; - } - degrees - } - #[expect( clippy::too_many_arguments, reason = "structure placement call mirrors vanilla template placement context" diff --git a/steel-registry/build/dimension_types.rs b/steel-registry/build/dimension_types.rs index 55d64622989a..a6c602bd1171 100644 --- a/steel-registry/build/dimension_types.rs +++ b/steel-registry/build/dimension_types.rs @@ -180,8 +180,8 @@ fn generate_monster_spawn_light_level(level: &MonsterSpawnLightLevelJson) -> Tok } fn generate_bed_rule(bed_rule: &BedRuleJson) -> TokenStream { - let can_set_spawn = bed_rule.can_set_spawn.as_str(); - let can_sleep = bed_rule.can_sleep.as_str(); + let can_set_spawn = generate_bed_rule_value(&bed_rule.can_set_spawn); + let can_sleep = generate_bed_rule_value(&bed_rule.can_sleep); let explodes = bed_rule.explodes; let error_message_key = generate_option( &bed_rule.error_message.as_ref().map(|m| m.translate.clone()), @@ -200,6 +200,15 @@ fn generate_bed_rule(bed_rule: &BedRuleJson) -> TokenStream { } } +fn generate_bed_rule_value(value: &str) -> TokenStream { + match value { + "always" => quote! { BedRuleValue::Always }, + "when_dark" => quote! { BedRuleValue::WhenDark }, + "never" => quote! { BedRuleValue::Never }, + _ => panic!("Invalid bed rule value '{value}'"), + } +} + fn generate_mood_sound(mood: &MoodJson) -> TokenStream { let sound = parse_sound_event(&mood.sound); let sound = generate_sound_event_ref(&sound); @@ -287,7 +296,7 @@ pub(crate) fn build() -> TokenStream { stream.extend(quote! { use crate::dimension_type::{ DimensionType, DimensionTypeRegistry, MonsterSpawnLightLevel, - BedRule, MoodSound, MusicEntry, BackgroundMusic, + BedRule, BedRuleValue, MoodSound, MusicEntry, BackgroundMusic, }; use steel_utils::Identifier; use std::borrow::Cow; diff --git a/steel-registry/src/dimension_type.rs b/steel-registry/src/dimension_type.rs index 3e7e9ef27018..225cce61c74f 100644 --- a/steel-registry/src/dimension_type.rs +++ b/steel-registry/src/dimension_type.rs @@ -7,12 +7,36 @@ use crate::sound_event::SoundEventRef; #[derive(Debug)] pub struct BedRule { - pub can_set_spawn: &'static str, - pub can_sleep: &'static str, + pub can_set_spawn: BedRuleValue, + pub can_sleep: BedRuleValue, pub explodes: bool, pub error_message_key: Option<&'static str>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BedRuleValue { + Always, + WhenDark, + Never, +} + +impl BedRuleValue { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Always => "always", + Self::WhenDark => "when_dark", + Self::Never => "never", + } + } +} + +impl ToNbtTag for BedRuleValue { + fn to_nbt_tag(self) -> NbtTag { + self.as_str().to_nbt_tag() + } +} + #[derive(Debug)] pub struct MoodSound { pub sound: SoundEventRef, diff --git a/steel-utils/src/angle.rs b/steel-utils/src/angle.rs new file mode 100644 index 000000000000..cac34633e8a4 --- /dev/null +++ b/steel-utils/src/angle.rs @@ -0,0 +1,33 @@ +//! # Angle utilities + +/// Wraps an angle in degrees to the range [-180, 180) +#[must_use] +pub fn wrap_degrees(mut degrees: f32) -> f32 { + degrees %= 360.0; + if degrees >= 180.0 { + degrees -= 360.0; + } + if degrees < -180.0 { + degrees += 360.0; + } + degrees +} + +/// Converts a rotation in degrees to vanilla 16 segment rotation value +#[must_use] +pub fn convert_to_rotation_segment(degrees: f32) -> u8 { + (((degrees.rem_euclid(360.0) / 22.5) + 0.5) as u8) & 15 +} + +#[cfg(test)] +mod tests { + use super::wrap_degrees; + + #[test] + fn wrap_degrees_matches_vanilla_range() { + assert_eq!(wrap_degrees(181.0).to_bits(), (-179.0_f32).to_bits()); + assert_eq!(wrap_degrees(-181.0).to_bits(), 179.0_f32.to_bits()); + assert_eq!(wrap_degrees(90.0).to_bits(), 90.0_f32.to_bits()); + assert_eq!(wrap_degrees(540.0).to_bits(), (-180.0_f32).to_bits()); + } +} diff --git a/steel-utils/src/axis.rs b/steel-utils/src/axis.rs index 340207aae33e..9b3f3e35a01a 100644 --- a/steel-utils/src/axis.rs +++ b/steel-utils/src/axis.rs @@ -1,3 +1,5 @@ +//! Axis types + /// An axis in 3D space. #[derive(Copy, Clone, Debug, Eq)] #[derive_const(PartialEq)] diff --git a/steel-utils/src/direction.rs b/steel-utils/src/direction.rs index 3c36fa380c52..d866c55723d8 100644 --- a/steel-utils/src/direction.rs +++ b/steel-utils/src/direction.rs @@ -106,19 +106,12 @@ impl Direction { } /// Returns the horizontal direction from a yaw rotation. - /// - /// Yaw values follow Minecraft's convention: - /// - 0° = South (+Z) - /// - 90° = West (-X) - /// - 180° = North (-Z) - /// - 270° = East (+X) #[must_use] pub fn from_yaw(yaw: f32) -> Direction { - let adjusted = yaw.rem_euclid(360.0); - match adjusted { - y if !(45.0..315.0).contains(&y) => Direction::South, - y if y < 135.0 => Direction::West, - y if y < 225.0 => Direction::North, + match ((f64::from(yaw) / 90.0 + 0.5).floor() as i32) & 3 { + 0 => Direction::South, + 1 => Direction::West, + 2 => Direction::North, _ => Direction::East, } } @@ -324,6 +317,18 @@ impl Direction { } } + /// Returns whether this direction is facing the given yaw angle + #[must_use] + pub fn is_facing_angle(self, yaw: f32) -> bool { + let radians = yaw.to_radians(); + let dx = -radians.sin(); + let dz = radians.cos(); + + let (nx, nz) = self.offset_xz(); + + (nx as f32) * dx + (nz as f32) * dz > 0.0 + } + /// Returns a random direction #[must_use] pub fn random() -> Self { @@ -372,6 +377,32 @@ mod tests { ); } + #[test] + fn from_yaw_matches_vanilla_from_y_rot_boundaries() { + let cases = [ + (0.0, Direction::South), + (44.999, Direction::South), + (45.0, Direction::West), + (134.999, Direction::West), + (135.0, Direction::North), + (224.999, Direction::North), + (225.0, Direction::East), + (314.999, Direction::East), + (315.0, Direction::South), + (-44.999, Direction::South), + (-45.0, Direction::South), + (-45.001, Direction::East), + (-135.0, Direction::East), + (-225.0, Direction::North), + (-315.0, Direction::West), + (405.0, Direction::West), + ]; + + for (yaw, direction) in cases { + assert_eq!(Direction::from_yaw(yaw), direction, "yaw {yaw}"); + } + } + #[test] fn positive_for_axis_matches_vanilla_axis_direction_positive() { assert_eq!(Direction::positive_for_axis(Axis::X), Direction::East); diff --git a/steel-utils/src/lib.rs b/steel-utils/src/lib.rs index 706dad7dd248..6547db03597a 100644 --- a/steel-utils/src/lib.rs +++ b/steel-utils/src/lib.rs @@ -8,6 +8,7 @@ pub const MC_VERSION: &str = "26.2"; /// axis +pub mod angle; pub mod axis; /// Vanilla `BlockUtil` helpers. pub mod block_util; @@ -54,6 +55,7 @@ pub mod translations_registry; #[expect(missing_docs, warnings)] pub mod entity_events; +pub use angle::wrap_degrees; pub use direction::Direction; pub use downcast::{Downcast, DowncastType, DowncastTypeKey, ErasedType}; pub use front_vec::FrontVec;