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