Skip to main content

steel_core/behavior/blocks/redstone/
button_block.rs

1//! Button block behavior.
2//!
3//! Buttons are face-attached blocks that emit a redstone signal when pressed.
4//! They automatically unpress after a delay via the scheduled tick system.
5//!
6//! Vanilla equivalent: `ButtonBlock` + `FaceAttachedHorizontalDirectionalBlock`.
7
8use std::sync::Arc;
9
10use steel_macros::block_behavior;
11use steel_registry::blocks::BlockRef;
12use steel_registry::blocks::block_state_ext::BlockStateExt;
13use steel_registry::blocks::properties::{BlockStateProperties, BoolProperty, Direction};
14use steel_registry::sound_event::SoundEventRef;
15use steel_registry::vanilla_game_events;
16use steel_utils::types::UpdateFlags;
17use steel_utils::{BlockPos, BlockStateId};
18
19use crate::behavior::InventoryAccess;
20use crate::behavior::block::BlockBehavior;
21use crate::behavior::blocks::face_attached_horizontal_directional_block::FaceAttachedHorizontalDirectionalBlock;
22use crate::behavior::blocks::redstone::{MAX_REDSTONE_SIGNAL, MIN_REDSTONE_SIGNAL};
23use crate::behavior::context::{BlockHitResult, BlockPlaceContext, InteractionResult};
24use crate::entity::{Entity, InsideBlockEffectCollector, SharedEntity};
25use crate::player::Player;
26use crate::world::{
27    LevelReader, ScheduledTickAccess, SignalQueryContext, World, game_event::GameEventContext,
28};
29
30/// Behavior for all button block variants.
31///
32/// Stone buttons stay pressed for 20 ticks, wood buttons for 30 ticks.
33/// Each variant has its own click on/off sounds determined by the block set type.
34#[block_behavior]
35pub struct ButtonBlock {
36    face_attached: FaceAttachedHorizontalDirectionalBlock,
37    #[json_arg(value)]
38    ticks_to_stay_pressed: i32,
39    #[json_arg(value, json = "type_can_button_be_activated_by_arrows")]
40    arrow_sensitive: bool,
41    #[json_arg(sound_events, json = "type_button_click_on")]
42    sound_click_on: SoundEventRef,
43    #[json_arg(sound_events, json = "type_button_click_off")]
44    sound_click_off: SoundEventRef,
45}
46
47const POWERED: &BoolProperty = &BlockStateProperties::POWERED;
48
49impl ButtonBlock {
50    /// Creates a new button block behavior.
51    ///
52    /// Parameters are provided by the build system from `classes.json`.
53    #[must_use]
54    pub const fn new(
55        block: BlockRef,
56        ticks_to_stay_pressed: i32,
57        arrow_sensitive: bool,
58        sound_click_on: SoundEventRef,
59        sound_click_off: SoundEventRef,
60    ) -> Self {
61        Self {
62            face_attached: FaceAttachedHorizontalDirectionalBlock::new(block),
63            ticks_to_stay_pressed,
64            arrow_sensitive,
65            sound_click_on,
66            sound_click_off,
67        }
68    }
69
70    /// Updates neighbors at both the button position and the support block position.
71    ///
72    /// Vanilla equivalent: `ButtonBlock.updateNeighbors()`.
73    fn update_button_neighbors(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
74        world.update_neighbors_at(pos, self.face_attached.block);
75        let support_dir =
76            FaceAttachedHorizontalDirectionalBlock::connected_direction(state).opposite();
77        let support_pos = support_dir.relative(pos);
78        world.update_neighbors_at(support_pos, self.face_attached.block);
79    }
80
81    /// Presses the button: sets POWERED=true, updates neighbors, schedules unpress tick,
82    /// and plays the click sound.
83    fn press(
84        &self,
85        state: BlockStateId,
86        world: &Arc<World>,
87        pos: BlockPos,
88        player: Option<&Player>,
89    ) {
90        let powered_state = state.set_value(POWERED, true);
91        world.set_block(pos, powered_state, UpdateFlags::UPDATE_ALL);
92        self.update_button_neighbors(powered_state, world, pos);
93        world.schedule_block_tick_default(
94            pos,
95            self.face_attached.block,
96            self.ticks_to_stay_pressed,
97        );
98        world.play_block_sound(self.sound_click_on, pos, 1.0, 1.0, player.map(Player::id));
99        world.game_event(
100            &vanilla_game_events::BLOCK_ACTIVATE,
101            pos,
102            &GameEventContext::new(player.map(|player| player as &dyn Entity), None),
103        );
104    }
105
106    fn first_arrow(
107        &self,
108        state: BlockStateId,
109        world: &World,
110        pos: BlockPos,
111    ) -> Option<SharedEntity> {
112        if !self.arrow_sensitive {
113            return None;
114        }
115        let bounds = state.get_outline_shape_at(pos).bounds()?.at_block(pos);
116        world
117            .get_entities_in_aabb_matching(&bounds, Entity::is_abstract_arrow)
118            .into_iter()
119            .next()
120    }
121
122    fn check_pressed(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
123        let first_arrow = self.first_arrow(state, world, pos);
124        let should_be_pressed = first_arrow.is_some();
125        let was_pressed = state.get_value(POWERED);
126        if should_be_pressed != was_pressed {
127            world.set_block(
128                pos,
129                state.set_value(POWERED, should_be_pressed),
130                UpdateFlags::UPDATE_ALL,
131            );
132            self.update_button_neighbors(state, world, pos);
133            world.play_block_sound(
134                if should_be_pressed {
135                    self.sound_click_on
136                } else {
137                    self.sound_click_off
138                },
139                pos,
140                1.0,
141                1.0,
142                None,
143            );
144            world.game_event(
145                if should_be_pressed {
146                    &vanilla_game_events::BLOCK_ACTIVATE
147                } else {
148                    &vanilla_game_events::BLOCK_DEACTIVATE
149                },
150                pos,
151                &GameEventContext::new(first_arrow.as_deref(), None),
152            );
153        }
154
155        if should_be_pressed {
156            world.schedule_block_tick_default(
157                pos,
158                self.face_attached.block,
159                self.ticks_to_stay_pressed,
160            );
161        }
162    }
163}
164
165impl BlockBehavior for ButtonBlock {
166    /// Checks if a button with the given state can survive at the given position.
167    fn can_survive(&self, state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
168        FaceAttachedHorizontalDirectionalBlock::can_survive(state, world, pos)
169    }
170
171    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
172        self.face_attached.state_for_placement(context)
173    }
174
175    fn update_shape(
176        &self,
177        state: BlockStateId,
178        world: &dyn ScheduledTickAccess,
179        pos: BlockPos,
180        direction: Direction,
181        _neighbor_pos: BlockPos,
182        _neighbor_state: BlockStateId,
183    ) -> BlockStateId {
184        FaceAttachedHorizontalDirectionalBlock::update_shape(state, world, pos, direction)
185    }
186
187    fn use_without_item(
188        &self,
189        state: BlockStateId,
190        world: &Arc<World>,
191        pos: BlockPos,
192        player: &Player,
193        _hit_result: &BlockHitResult,
194        _inv: &mut InventoryAccess,
195    ) -> InteractionResult {
196        let powered: bool = state.get_value(POWERED);
197        if powered {
198            return InteractionResult::Consume;
199        }
200        self.press(state, world, pos, Some(player));
201        InteractionResult::Success
202    }
203
204    fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
205        let powered: bool = state.get_value(POWERED);
206        if !powered {
207            return;
208        }
209        self.check_pressed(state, world, pos);
210    }
211
212    fn entity_inside(
213        &self,
214        state: BlockStateId,
215        world: &Arc<World>,
216        pos: BlockPos,
217        _entity: &dyn Entity,
218        _effect_collector: &mut InsideBlockEffectCollector,
219        _is_precise: bool,
220    ) {
221        if self.arrow_sensitive && !state.get_value(POWERED) {
222            self.check_pressed(state, world, pos);
223        }
224    }
225
226    fn affect_neighbors_after_removal(
227        &self,
228        state: BlockStateId,
229        world: &Arc<World>,
230        pos: BlockPos,
231        moved_by_piston: bool,
232    ) {
233        if moved_by_piston {
234            return;
235        }
236        let powered: bool = state.get_value(POWERED);
237        if !powered {
238            return;
239        }
240        self.update_button_neighbors(state, world, pos);
241    }
242
243    fn is_signal_source(&self, _state: BlockStateId, _context: SignalQueryContext) -> bool {
244        true
245    }
246
247    fn get_own_signal(
248        &self,
249        state: BlockStateId,
250        _world: &dyn LevelReader,
251        _pos: BlockPos,
252        _context: SignalQueryContext,
253    ) -> i32 {
254        if state.get_value(POWERED) {
255            MAX_REDSTONE_SIGNAL
256        } else {
257            MIN_REDSTONE_SIGNAL
258        }
259    }
260
261    fn get_direct_signal(
262        &self,
263        state: BlockStateId,
264        _world: &dyn LevelReader,
265        _pos: BlockPos,
266        direction: Direction,
267        _context: SignalQueryContext,
268    ) -> i32 {
269        if state.get_value(POWERED)
270            && FaceAttachedHorizontalDirectionalBlock::connected_direction(state) == direction
271        {
272            MAX_REDSTONE_SIGNAL
273        } else {
274            MIN_REDSTONE_SIGNAL
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use std::sync::Arc;
282
283    use glam::DVec3;
284    use steel_registry::init_vanilla_registry;
285    use steel_registry::{vanilla_blocks, vanilla_entities};
286    use steel_utils::ChunkPos;
287
288    use super::*;
289    use crate::behavior::{BLOCK_BEHAVIORS, init_behaviors};
290    use crate::entity::{InsideBlockEffectCollector, SharedEntity};
291    use crate::test_support::{TestEntity, fresh_test_world, insert_ready_full_chunk};
292
293    #[test]
294    fn wooden_button_stays_pressed_while_arrow_intersects_its_shape() {
295        init_vanilla_registry();
296        init_behaviors();
297        let world = fresh_test_world("wooden_button_arrow");
298        let pos = BlockPos::new(8, 64, 8);
299        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
300        let state = vanilla_blocks::OAK_BUTTON.default_state();
301        assert!(world.set_block(pos, state, UpdateFlags::UPDATE_NONE));
302
303        let bounds = state
304            .get_outline_shape_at(pos)
305            .bounds()
306            .expect("button outline should be non-empty")
307            .at_block(pos);
308        let arrow_pos = DVec3::new(
309            f64::midpoint(bounds.min_x(), bounds.max_x()),
310            bounds.min_y(),
311            f64::midpoint(bounds.min_z(), bounds.max_z()),
312        );
313        let arrow: SharedEntity = TestEntity::shared(
314            7_001,
315            arrow_pos,
316            Arc::downgrade(&world),
317            &vanilla_entities::ARROW,
318        );
319        world
320            .try_add_entity(Arc::clone(&arrow))
321            .expect("test arrow should enter loaded chunk");
322
323        let mut effects = InsideBlockEffectCollector::new();
324        BLOCK_BEHAVIORS
325            .get_behavior(&vanilla_blocks::OAK_BUTTON)
326            .entity_inside(state, &world, pos, arrow.as_ref(), &mut effects, true);
327
328        assert!(world.get_block_state(pos).get_value(POWERED));
329        assert!(world.has_scheduled_block_tick(pos, &vanilla_blocks::OAK_BUTTON));
330    }
331}