feat: emit lectern redstone signals and add the lectern screen (#2759)

Implements the missing redstone half of the lectern:
- page turns pulse the powered state for 2 game ticks (vanilla parity),
  strongly powering the block below and playing the page-turn event
- placing/taking a book resets the pulse and notifies neighbors
- comparator output now uses the vanilla formula
  floor(page / (pageCount - 1) * 14) + 1 instead of a scaled variant
- adds the lectern screen handler (page buttons, jump-to-page and
  take-book) so the pulse can actually be triggered by players; the
  current page is synced as container property 0
- using a lectern with a book now opens the reading screen instead of
  silently deleting the book

Co-authored-by: Mcxiaocaibug <Mcxiaocaibug@users.noreply.github.com>
This commit is contained in:
Mcxiaocaibug
2026-08-05 14:56:31 +08:00
committed by GitHub
parent f0da5f330b
commit bff591e424
4 changed files with 436 additions and 80 deletions

View File

@@ -0,0 +1,143 @@
use std::any::Any;
use std::sync::Arc;
use crate::screen_handler::{
InventoryPlayer, ItemStackFuture, ScreenHandler, ScreenHandlerBehaviour, ScreenHandlerFuture,
ScreenProperty, offer_or_drop_stack,
};
use crate::slot::NormalSlot;
use pumpkin_data::item_stack::ItemStack;
use pumpkin_data::screen::WindowType;
use pumpkin_world::block::entities::PropertyDelegate;
use pumpkin_world::inventory::Inventory;
/// Callbacks into the lectern block so page turns and book removal can drive
/// block-state changes (redstone pulse, `has_book`) that live outside this crate.
pub trait LecternController: Send + Sync {
/// The page currently displayed.
fn current_page(&self) -> i32;
/// Clamps and persists `page`, emitting a redstone pulse when it changes.
fn set_page(&self, page: i32) -> ScreenHandlerFuture<'_, ()>;
/// Restores the bookless block state after the book was taken.
fn on_book_taken(&self) -> ScreenHandlerFuture<'_, ()>;
}
/// Exposes the current page as container property 0 (see `window_property::Lectern`).
struct PageDelegate(Arc<dyn LecternController>);
impl PropertyDelegate for PageDelegate {
fn get_property(&self, index: i32) -> i32 {
if index == 0 { self.0.current_page() } else { 0 }
}
fn set_property(&self, _index: i32, _value: i32) {}
fn get_properties_size(&self) -> i32 {
1
}
}
/// Vanilla `LecternScreenHandler`: a single book slot, no player slots and the
/// current page synced as property 0. Page navigation and taking the book are
/// plain button clicks sent by the client.
pub struct LecternScreenHandler {
behaviour: ScreenHandlerBehaviour,
inventory: Arc<dyn Inventory>,
controller: Arc<dyn LecternController>,
}
impl LecternScreenHandler {
const PREVIOUS_PAGE_BUTTON_ID: i32 = 1;
const NEXT_PAGE_BUTTON_ID: i32 = 2;
const TAKE_BOOK_BUTTON_ID: i32 = 3;
/// Button ids at or above this jump directly to `id - JUMP_TO_PAGE_OFFSET`.
const JUMP_TO_PAGE_OFFSET: i32 = 100;
pub fn new(
sync_id: u8,
inventory: Arc<dyn Inventory>,
controller: Arc<dyn LecternController>,
) -> Self {
let mut handler = Self {
behaviour: ScreenHandlerBehaviour::new(sync_id, Some(WindowType::Lectern)),
inventory: inventory.clone(),
controller: controller.clone(),
};
handler.add_slot(Arc::new(NormalSlot::new(inventory, 0)));
handler.add_property(ScreenProperty::new(Arc::new(PageDelegate(controller)), 0));
handler
}
}
impl ScreenHandler for LecternScreenHandler {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}
fn get_behaviour_mut(&mut self) -> &mut ScreenHandlerBehaviour {
&mut self.behaviour
}
fn on_button_click<'a>(
&'a mut self,
player: &'a dyn InventoryPlayer,
id: i32,
) -> ScreenHandlerFuture<'a, bool> {
Box::pin(async move {
match id {
Self::PREVIOUS_PAGE_BUTTON_ID => {
self.controller
.set_page(self.controller.current_page() - 1)
.await;
true
}
Self::NEXT_PAGE_BUTTON_ID => {
self.controller
.set_page(self.controller.current_page() + 1)
.await;
true
}
Self::TAKE_BOOK_BUTTON_ID => {
let stack = self.inventory.remove_stack(0).await;
if stack.is_empty() {
return false;
}
self.inventory.mark_dirty();
self.controller.on_book_taken().await;
offer_or_drop_stack(player, stack).await;
self.send_content_updates().await;
true
}
_ if id >= Self::JUMP_TO_PAGE_OFFSET => {
self.controller
.set_page(id - Self::JUMP_TO_PAGE_OFFSET)
.await;
true
}
_ => false,
}
})
}
fn quick_move<'a>(
&'a mut self,
_player: &'a dyn InventoryPlayer,
_slot_index: i32,
) -> ItemStackFuture<'a> {
// The lectern screen has no player slots, so nothing can be shift-clicked.
Box::pin(async move { ItemStack::EMPTY.clone() })
}
}

View File

@@ -43,6 +43,7 @@ mod error;
pub mod furnace_like;
pub mod generic_container_screen_handler;
pub mod gui_builder;
pub mod lectern_screen_handler;
pub mod merchant;
pub mod player;
pub mod screen_handler;

View File

@@ -1,39 +1,157 @@
use std::sync::Arc;
use std::sync::atomic::Ordering;
use crate::block::entities::lectern::LecternBlockEntity;
use crate::block::registry::BlockActionResult;
use crate::block::{
BlockBehaviour, BlockFuture, BrokenArgs, GetComparatorOutputArgs, NormalUseArgs, OnPlaceArgs,
BlockBehaviour, BlockFuture, BrokenArgs, EmitsRedstonePowerArgs, GetComparatorOutputArgs,
GetRedstonePowerArgs, NormalUseArgs, OnPlaceArgs, OnScheduledTickArgs, OnStateReplacedArgs,
PlacedArgs, UseWithItemArgs,
};
use crate::entity::Entity;
use crate::entity::item::ItemEntity;
use crate::world::World;
use pumpkin_data::Block;
use pumpkin_data::BlockStateId;
use pumpkin_data::block_properties::{BlockProperties, LecternLikeProperties};
use pumpkin_data::entity::EntityType;
use pumpkin_data::sound::{Sound, SoundCategory};
use pumpkin_data::tag::Taggable;
use pumpkin_data::world::WorldEvent;
use pumpkin_data::{Block, BlockDirection, BlockStateId, tag, translation};
use pumpkin_inventory::lectern_screen_handler::{LecternController, LecternScreenHandler};
use pumpkin_inventory::player::player_inventory::PlayerInventory;
use pumpkin_inventory::screen_handler::{
BoxFuture, InventoryPlayer, ScreenHandlerFactory, ScreenHandlerFuture, SharedScreenHandler,
};
use pumpkin_macros::pumpkin_block;
use pumpkin_util::math::position::BlockPos;
use pumpkin_util::math::vector3::Vector3;
use pumpkin_util::text::TextComponent;
use pumpkin_world::inventory::Inventory;
use pumpkin_world::tick::TickPriority;
use pumpkin_world::world::BlockFlags;
use tokio::sync::Mutex;
/// Bridges the screen handler back into the world: page changes emit the
/// vanilla redstone pulse and taking the book clears `has_book`.
struct LecternPageController {
world: Arc<World>,
position: BlockPos,
inventory: Arc<dyn Inventory>,
}
impl LecternPageController {
fn entity(&self) -> Option<&LecternBlockEntity> {
self.inventory.as_any().downcast_ref::<LecternBlockEntity>()
}
}
impl LecternController for LecternPageController {
fn current_page(&self) -> i32 {
self.entity()
.map_or(0, |entity| entity.page.load(Ordering::Relaxed) as i32)
}
fn set_page(&self, page: i32) -> ScreenHandlerFuture<'_, ()> {
Box::pin(async move {
let Some(entity) = self.entity() else {
return;
};
let page_count = entity.page_count().await;
let page = page.clamp(0, (page_count - 1).max(0));
if page == entity.page.load(Ordering::Relaxed) as i32 {
return;
}
entity.page.store(page as usize, Ordering::Relaxed);
entity.mark_dirty();
LecternBlock::pulse(&self.world, &self.position).await;
})
}
fn on_book_taken(&self) -> ScreenHandlerFuture<'_, ()> {
Box::pin(async move {
if let Some(entity) = self.entity() {
entity.page.store(0, Ordering::Relaxed);
}
LecternBlock::set_has_book(&self.world, &self.position, false).await;
})
}
}
struct LecternScreenFactory {
inventory: Arc<dyn Inventory>,
controller: Arc<dyn LecternController>,
}
impl ScreenHandlerFactory for LecternScreenFactory {
fn create_screen_handler<'a>(
&'a self,
sync_id: u8,
_player_inventory: &'a Arc<PlayerInventory>,
_player: &'a dyn InventoryPlayer,
) -> BoxFuture<'a, Option<SharedScreenHandler>> {
Box::pin(async move {
let handler =
LecternScreenHandler::new(sync_id, self.inventory.clone(), self.controller.clone());
Some(Arc::new(Mutex::new(handler)) as SharedScreenHandler)
})
}
fn get_display_name(&self) -> TextComponent {
TextComponent::translate_cross(
translation::java::CONTAINER_LECTERN,
translation::bedrock::TILE_LECTERN_NAME,
&[],
)
}
}
#[pumpkin_block("minecraft:lectern")]
pub struct LecternBlock;
impl LecternBlock {
async fn update_lectern_state(
has_book: bool,
block: &Block,
position: &BlockPos,
world: &Arc<World>,
props: &mut LecternLikeProperties,
) {
/// Vanilla pulse length of a page-turn signal, in game ticks.
const PAGE_TURN_PULSE_TICKS: u8 = 2;
/// The lectern strongly powers the block below it, so its neighbors need
/// updating whenever the power or book state changes.
async fn update_neighbors_below(world: &Arc<World>, position: &BlockPos) {
world.update_neighbors(&position.down(), None).await;
}
/// Emits the vanilla page-turn redstone pulse: powered for two game ticks.
pub(crate) async fn pulse(world: &Arc<World>, position: &BlockPos) {
let (block, state_id) = world.get_block_and_state_id(position);
if block != &Block::LECTERN {
return;
}
let mut props = LecternLikeProperties::from_state_id(state_id, block);
props.powered = true;
world
.set_block_state(position, props.to_state_id(block), BlockFlags::NOTIFY_ALL)
.await;
Self::update_neighbors_below(world, position).await;
world.schedule_block_tick(
block,
*position,
Self::PAGE_TURN_PULSE_TICKS,
TickPriority::Normal,
);
world.sync_world_event(WorldEvent::SoundPageTurn, *position, 0);
}
/// Sets `has_book`, dropping any pending pulse like vanilla `setHasBook`.
pub(crate) async fn set_has_book(world: &Arc<World>, position: &BlockPos, has_book: bool) {
let (block, state_id) = world.get_block_and_state_id(position);
if block != &Block::LECTERN {
return;
}
let mut props = LecternLikeProperties::from_state_id(state_id, block);
props.powered = false;
props.has_book = has_book;
world
.set_block_state(position, props.to_state_id(block), BlockFlags::NOTIFY_ALL)
.await;
Self::update_neighbors_below(world, position).await;
}
}
@@ -60,6 +178,21 @@ impl BlockBehaviour for LecternBlock {
fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> {
Box::pin(async move {
let props = LecternLikeProperties::from_state_id(
args.world.get_block_state(args.position).id,
args.block,
);
if !props.has_book {
return BlockActionResult::Pass;
}
let Some(block_entity) = args.world.get_block_entity(args.position) else {
return BlockActionResult::Pass;
};
let Some(inventory) = block_entity.get_inventory() else {
return BlockActionResult::Pass;
};
args.player
.increment_stat(
pumpkin_data::statistic::StatisticCategory::Custom,
@@ -67,31 +200,23 @@ impl BlockBehaviour for LecternBlock {
1,
)
.await;
if let Some(block_entity) = args.world.get_block_entity(args.position)
&& let Some(lectern_entity) =
block_entity.as_any().downcast_ref::<LecternBlockEntity>()
{
let book = lectern_entity.remove_stack(0).await;
if !book.is_empty() {
// Logic to give the book to the player
// Need to find a proper way to give items to player. For now skip.
let mut props = LecternLikeProperties::from_state_id(
args.world.get_block_state(args.position).id,
args.block,
);
Self::update_lectern_state(
false,
args.block,
args.position,
args.world,
&mut props,
)
.await;
return BlockActionResult::Success;
}
}
BlockActionResult::Pass
let controller = Arc::new(LecternPageController {
world: args.world.clone(),
position: *args.position,
inventory: inventory.clone(),
});
args.player
.open_handled_screen(
&LecternScreenFactory {
inventory,
controller,
},
Some(*args.position),
)
.await;
BlockActionResult::Success
})
}
@@ -101,26 +226,95 @@ impl BlockBehaviour for LecternBlock {
) -> BlockFuture<'a, BlockActionResult> {
Box::pin(async move {
let mut item_stack = args.item_stack.lock().await;
if !item_stack.item.has_tag(&tag::Item::MINECRAFT_LECTERN_BOOKS) {
return BlockActionResult::Pass;
}
// Check if it's a book
if item_stack.item.registry_key.contains("book")
&& let Some(block_entity) = args.world.get_block_entity(args.position)
&& let Some(lectern_entity) =
block_entity.as_any().downcast_ref::<LecternBlockEntity>()
&& lectern_entity.book.lock().await.is_empty()
{
let book = item_stack.split_unless_creative(args.player.gamemode.load(), 1);
lectern_entity.set_stack(0, book).await;
let mut props = LecternLikeProperties::from_state_id(
args.world.get_block_state(args.position).id,
args.block,
);
Self::update_lectern_state(true, args.block, args.position, args.world, &mut props)
.await;
return BlockActionResult::Success;
let props = LecternLikeProperties::from_state_id(
args.world.get_block_state(args.position).id,
args.block,
);
if props.has_book {
// Fall through so `normal_use` opens the reading screen.
return BlockActionResult::Pass;
}
let Some(lectern) = args.world.get_block_entity(args.position) else {
return BlockActionResult::Pass;
};
let Some(lectern) = lectern.as_any().downcast_ref::<LecternBlockEntity>() else {
return BlockActionResult::Pass;
};
let book = item_stack.split_unless_creative(args.player.gamemode.load(), 1);
drop(item_stack);
lectern.set_stack(0, book).await;
Self::set_has_book(args.world, args.position, true).await;
args.world
.play_block_sound(Sound::ItemBookPut, SoundCategory::Blocks, *args.position);
BlockActionResult::Success
})
}
fn on_scheduled_tick<'a>(&'a self, args: OnScheduledTickArgs<'a>) -> BlockFuture<'a, ()> {
Box::pin(async move {
let mut props = LecternLikeProperties::from_state_id(
args.world.get_block_state(args.position).id,
args.block,
);
props.powered = false;
args.world
.set_block_state(
args.position,
props.to_state_id(args.block),
BlockFlags::NOTIFY_ALL,
)
.await;
Self::update_neighbors_below(args.world, args.position).await;
})
}
fn emits_redstone_power<'a>(
&'a self,
_args: EmitsRedstonePowerArgs<'a>,
) -> BlockFuture<'a, bool> {
Box::pin(async move { true })
}
fn get_weak_redstone_power<'a>(
&'a self,
args: GetRedstonePowerArgs<'a>,
) -> BlockFuture<'a, u8> {
Box::pin(async move {
let props = LecternLikeProperties::from_state_id(args.state.id, args.block);
if props.powered { 15 } else { 0 }
})
}
fn get_strong_redstone_power<'a>(
&'a self,
args: GetRedstonePowerArgs<'a>,
) -> BlockFuture<'a, u8> {
Box::pin(async move {
let props = LecternLikeProperties::from_state_id(args.state.id, args.block);
if props.powered && args.direction == BlockDirection::Up {
15
} else {
0
}
})
}
fn on_state_replaced<'a>(&'a self, args: OnStateReplacedArgs<'a>) -> BlockFuture<'a, ()> {
Box::pin(async move {
if !args.moved {
let props = LecternLikeProperties::from_state_id(args.old_state_id, args.block);
if props.powered {
Self::update_neighbors_below(args.world, args.position).await;
}
}
BlockActionResult::Pass
})
}
@@ -158,28 +352,7 @@ impl BlockBehaviour for LecternBlock {
&& let Some(lectern_entity) =
block_entity.as_any().downcast_ref::<LecternBlockEntity>()
{
let book_guard = lectern_entity.book.lock().await;
if book_guard.is_empty() {
return Some(0);
}
let page = lectern_entity
.page
.load(std::sync::atomic::Ordering::Relaxed) as f32;
let mut pages = 1.0;
if let Some(comp) = book_guard.get_data_component::<pumpkin_data::data_component_impl::WrittenBookContentImpl>() {
pages = comp.pages.len().max(1) as f32;
} else if let Some(comp) = book_guard.get_data_component::<pumpkin_data::data_component_impl::WritableBookContentImpl>() {
pages = comp.pages.len().max(1) as f32;
}
let output: f32 = if pages > 1.0 {
1.0 + 14.0 * page / (pages - 1.0)
} else {
15.0
};
Some(output.floor() as u8)
Some(lectern_entity.comparator_output().await)
} else {
Some(0)
}

View File

@@ -1,3 +1,4 @@
use pumpkin_data::data_component_impl::{WritableBookContentImpl, WrittenBookContentImpl};
use pumpkin_data::item_stack::ItemStack;
use pumpkin_nbt::compound::NbtCompound;
use pumpkin_nbt::tag::NbtTag;
@@ -36,15 +37,17 @@ impl BlockEntity for LecternBlockEntity {
where
Self: Sized,
{
let book = nbt
let book_stack = nbt
.get_compound("Book")
.and_then(ItemStack::read_item_stack)
.map_or_else(
|| Arc::new(Mutex::new(ItemStack::EMPTY.clone())),
|stack| Arc::new(Mutex::new(stack)),
);
.unwrap_or_else(|| ItemStack::EMPTY.clone());
let page = nbt.get_int("Page").unwrap_or(0).max(0) as usize;
let page_count = Self::page_count_of(&book_stack);
let page = nbt
.get_int("Page")
.unwrap_or(0)
.clamp(0, page_count.saturating_sub(1).max(0)) as usize;
let book = Arc::new(Mutex::new(book_stack));
Self {
position,
@@ -111,6 +114,40 @@ impl LecternBlockEntity {
dirty: AtomicBool::new(false),
}
}
/// Number of pages in a writable or written book, `0` for anything else.
#[must_use]
pub fn page_count_of(stack: &ItemStack) -> i32 {
stack
.get_data_component::<WrittenBookContentImpl>()
.map(|content| content.pages.len())
.or_else(|| {
stack
.get_data_component::<WritableBookContentImpl>()
.map(|content| content.pages.len())
})
.map_or(0, |pages| pages as i32)
}
pub async fn page_count(&self) -> i32 {
Self::page_count_of(&*self.book.lock().await)
}
/// Vanilla comparator output: `floor(page / (page_count - 1) * 14) + 1`,
/// or `0` without a book. Single-page books emit `1` (`0 / 0` is `NaN`,
/// which vanilla's `MathHelper.floor` turns into `0`).
pub async fn comparator_output(&self) -> u8 {
let book = self.book.lock().await;
if book.is_empty() {
return 0;
}
let page = self.page.load(Ordering::Relaxed) as f32;
let page_count = Self::page_count_of(&book) as f32;
let fraction = page / (page_count - 1.0) * 14.0;
// `NaN as u8` is 0, matching vanilla's cast of NaN to int.
fraction.floor() as u8 + 1
}
}
impl Inventory for LecternBlockEntity {
@@ -151,6 +188,8 @@ impl Inventory for LecternBlockEntity {
fn set_stack(&self, _slot: usize, stack: ItemStack) -> InventoryFuture<'_, ()> {
Box::pin(async move {
*self.book.lock().await = stack;
// A freshly placed book always opens on its first page.
self.page.store(0, Ordering::Relaxed);
self.mark_dirty();
})
}