Skip to main content

steel_core/behavior/blocks/redstone/tripwire/
hook.rs

1//! Vanilla tripwire-hook line resolution and redstone output.
2
3use std::sync::Arc;
4
5use steel_macros::block_behavior;
6use steel_registry::blocks::BlockRef;
7use steel_registry::blocks::block_state_ext::BlockStateExt as _;
8use steel_registry::blocks::properties::{
9    BlockStateProperties, BoolProperty, Direction, EnumProperty,
10};
11use steel_registry::{sound_events, vanilla_blocks, vanilla_game_events};
12use steel_utils::axis::Axis;
13use steel_utils::types::UpdateFlags;
14use steel_utils::{BlockPos, BlockStateId};
15
16use crate::behavior::blocks::redstone::{MAX_REDSTONE_SIGNAL, MIN_REDSTONE_SIGNAL};
17use crate::behavior::{BlockBehavior, BlockPlaceContext, PlacementSource};
18use crate::world::game_event::GameEventContext;
19use crate::world::{LevelReader, ScheduledTickAccess, SignalQueryContext, World};
20
21const WIRE_DISTANCE_MAX: usize = 42;
22const RECHECK_PERIOD: i32 = 10;
23
24/// Vanilla `TripWireHookBlock` behavior.
25#[block_behavior]
26pub struct TripWireHookBlock {
27    block: BlockRef,
28}
29
30const ATTACHED: &BoolProperty = &BlockStateProperties::ATTACHED;
31const DISARMED: &BoolProperty = &BlockStateProperties::DISARMED;
32const HORIZONTAL_FACING: &EnumProperty<Direction> = &BlockStateProperties::HORIZONTAL_FACING;
33const POWERED: &BoolProperty = &BlockStateProperties::POWERED;
34
35impl TripWireHookBlock {
36    /// Creates tripwire-hook behavior.
37    #[must_use]
38    pub const fn new(block: BlockRef) -> Self {
39        Self { block }
40    }
41
42    fn notify_neighbors(block: BlockRef, world: &Arc<World>, pos: BlockPos, direction: Direction) {
43        let front = direction.opposite();
44        // Experimental redstone orientations are intentionally omitted.
45        world.update_neighbors_at(pos, block);
46        world.update_neighbors_at(pos.relative(front), block);
47    }
48
49    #[expect(
50        clippy::fn_params_excessive_bools,
51        reason = "booleans mirror vanilla's before/after tripwire state transition"
52    )]
53    fn emit_state(
54        world: &Arc<World>,
55        pos: BlockPos,
56        attached: bool,
57        powered: bool,
58        was_attached: bool,
59        was_powered: bool,
60    ) {
61        let (sound, pitch, event) = if powered && !was_powered {
62            (
63                &sound_events::BLOCK_TRIPWIRE_CLICK_ON,
64                0.6,
65                &vanilla_game_events::BLOCK_ACTIVATE,
66            )
67        } else if !powered && was_powered {
68            (
69                &sound_events::BLOCK_TRIPWIRE_CLICK_OFF,
70                0.5,
71                &vanilla_game_events::BLOCK_DEACTIVATE,
72            )
73        } else if attached && !was_attached {
74            (
75                &sound_events::BLOCK_TRIPWIRE_ATTACH,
76                0.7,
77                &vanilla_game_events::BLOCK_ATTACH,
78            )
79        } else if !attached && was_attached {
80            (
81                &sound_events::BLOCK_TRIPWIRE_DETACH,
82                1.2 / rand::random_range(0.9f32..1.1),
83                &vanilla_game_events::BLOCK_DETACH,
84            )
85        } else {
86            return;
87        };
88        world.play_block_sound(sound, pos, 0.4, pitch, None);
89        world.game_event(event, pos, &GameEventContext::default());
90    }
91
92    pub(super) fn calculate_state(
93        world: &Arc<World>,
94        pos: BlockPos,
95        state: BlockStateId,
96        is_being_destroyed: bool,
97        can_update: bool,
98        wire_source: i32,
99        wire_source_state: Option<BlockStateId>,
100    ) {
101        let direction = state.get_value(HORIZONTAL_FACING);
102        let was_attached = state.get_value(ATTACHED);
103        let was_powered = state.get_value(POWERED);
104        let block = state.get_block();
105        let mut attached = !is_being_destroyed;
106        let mut powered = false;
107        let mut receiver_distance = 0_usize;
108        let mut wire_states = [None; WIRE_DISTANCE_MAX];
109
110        for (distance, slot) in wire_states.iter_mut().enumerate().skip(1) {
111            let test_pos = pos.relative_n(direction, distance as i32);
112            let mut wire_state = world.get_block_state(test_pos);
113            if wire_state.get_block() == &vanilla_blocks::TRIPWIRE_HOOK {
114                if wire_state.get_value(HORIZONTAL_FACING) == direction.opposite() {
115                    receiver_distance = distance;
116                }
117                break;
118            }
119
120            if wire_state.get_block() != &vanilla_blocks::TRIPWIRE && distance as i32 != wire_source
121            {
122                attached = false;
123                continue;
124            }
125
126            if distance as i32 == wire_source
127                && let Some(source_state) = wire_source_state
128            {
129                wire_state = source_state;
130            }
131            let wire_armed = !wire_state.get_value(DISARMED);
132            let wire_powered = wire_state.get_value(POWERED);
133            powered |= wire_armed && wire_powered;
134            *slot = Some(wire_state);
135            if distance as i32 == wire_source {
136                world.schedule_block_tick_default(pos, block, RECHECK_PERIOD);
137                attached &= wire_armed;
138            }
139        }
140
141        attached &= receiver_distance > 1;
142        powered &= attached;
143        let new_state = block
144            .default_state()
145            .set_value(ATTACHED, attached)
146            .set_value(POWERED, powered);
147
148        if receiver_distance > 0 {
149            let receiver_pos = pos.relative_n(direction, receiver_distance as i32);
150            let opposite = direction.opposite();
151            world.set_block(
152                receiver_pos,
153                new_state.set_value(HORIZONTAL_FACING, opposite),
154                UpdateFlags::UPDATE_ALL,
155            );
156            Self::notify_neighbors(block, world, receiver_pos, opposite);
157            if world.get_block_state(pos).get_block() != &vanilla_blocks::TRIPWIRE_HOOK {
158                Self::on_removed(new_state, world, pos);
159                return;
160            }
161            Self::emit_state(
162                world,
163                receiver_pos,
164                attached,
165                powered,
166                was_attached,
167                was_powered,
168            );
169        }
170
171        Self::emit_state(world, pos, attached, powered, was_attached, was_powered);
172        if !is_being_destroyed {
173            world.set_block(
174                pos,
175                new_state.set_value(HORIZONTAL_FACING, direction),
176                UpdateFlags::UPDATE_ALL,
177            );
178            if can_update {
179                Self::notify_neighbors(block, world, pos, direction);
180            }
181        }
182
183        if was_attached != attached {
184            for (distance, wire_state) in wire_states
185                .iter()
186                .enumerate()
187                .take(receiver_distance)
188                .skip(1)
189            {
190                let Some(wire_state) = wire_state else {
191                    continue;
192                };
193                let test_pos = pos.relative_n(direction, distance as i32);
194                let live_state = world.get_block_state(test_pos);
195                if live_state.get_block() == &vanilla_blocks::TRIPWIRE
196                    || live_state.get_block() == &vanilla_blocks::TRIPWIRE_HOOK
197                {
198                    world.set_block(
199                        test_pos,
200                        wire_state.set_value(ATTACHED, attached),
201                        UpdateFlags::UPDATE_ALL,
202                    );
203                }
204            }
205        }
206    }
207
208    fn on_removed(state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
209        let attached = state.get_value(ATTACHED);
210        let powered = state.get_value(POWERED);
211        if attached || powered {
212            Self::calculate_state(world, pos, state, true, false, -1, None);
213        }
214        if powered {
215            Self::notify_neighbors(
216                state.get_block(),
217                world,
218                pos,
219                state.get_value(HORIZONTAL_FACING),
220            );
221        }
222    }
223}
224
225impl BlockBehavior for TripWireHookBlock {
226    fn can_survive(&self, state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
227        let direction = state.get_value(HORIZONTAL_FACING);
228        let support_pos = pos.relative(direction.opposite());
229        direction.axis() != Axis::Y
230            && world.is_face_sturdy(world.get_block_state(support_pos), support_pos, direction)
231    }
232
233    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
234        for direction in context.get_nearest_looking_directions() {
235            if direction.axis() == Axis::Y {
236                continue;
237            }
238            let state = self
239                .block
240                .default_state()
241                .set_value(HORIZONTAL_FACING, direction.opposite())
242                .set_value(POWERED, false)
243                .set_value(ATTACHED, false);
244            if self.can_survive(state, context.world.as_ref(), context.place_pos()) {
245                return Some(state);
246            }
247        }
248        None
249    }
250
251    fn update_shape(
252        &self,
253        state: BlockStateId,
254        world: &dyn ScheduledTickAccess,
255        pos: BlockPos,
256        direction: Direction,
257        _neighbor_pos: BlockPos,
258        _neighbor_state: BlockStateId,
259    ) -> BlockStateId {
260        if direction.opposite() == state.get_value(HORIZONTAL_FACING)
261            && !self.can_survive(state, world, pos)
262        {
263            vanilla_blocks::AIR.default_state()
264        } else {
265            state
266        }
267    }
268
269    fn set_placed_by(
270        &self,
271        state: BlockStateId,
272        world: &Arc<World>,
273        pos: BlockPos,
274        _source: &PlacementSource<'_>,
275    ) {
276        Self::calculate_state(world, pos, state, false, false, -1, None);
277    }
278
279    fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
280        Self::calculate_state(world, pos, state, false, true, -1, None);
281    }
282
283    fn affect_neighbors_after_removal(
284        &self,
285        state: BlockStateId,
286        world: &Arc<World>,
287        pos: BlockPos,
288        moved_by_piston: bool,
289    ) {
290        if !moved_by_piston {
291            Self::on_removed(state, world, pos);
292        }
293    }
294
295    fn is_signal_source(&self, _state: BlockStateId, _context: SignalQueryContext) -> bool {
296        true
297    }
298
299    fn get_own_signal(
300        &self,
301        state: BlockStateId,
302        _world: &dyn LevelReader,
303        _pos: BlockPos,
304        _context: SignalQueryContext,
305    ) -> i32 {
306        if state.get_value(POWERED) {
307            MAX_REDSTONE_SIGNAL
308        } else {
309            MIN_REDSTONE_SIGNAL
310        }
311    }
312
313    fn get_direct_signal(
314        &self,
315        state: BlockStateId,
316        _world: &dyn LevelReader,
317        _pos: BlockPos,
318        direction: Direction,
319        _context: SignalQueryContext,
320    ) -> i32 {
321        if state.get_value(POWERED) && state.get_value(HORIZONTAL_FACING) == direction {
322            MAX_REDSTONE_SIGNAL
323        } else {
324            MIN_REDSTONE_SIGNAL
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use steel_registry::init_vanilla_registry;
332    use steel_utils::ChunkPos;
333
334    use super::*;
335    use crate::behavior::init_behaviors;
336    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
337
338    #[test]
339    fn line_attachment_power_and_disarming_match_vanilla() {
340        init_vanilla_registry();
341        init_behaviors();
342        let world = fresh_test_world("tripwire_line");
343        let left = BlockPos::new(5, 64, 8);
344        let right = BlockPos::new(9, 64, 8);
345        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(left));
346        assert!(world.set_block(
347            left.west(),
348            vanilla_blocks::STONE.default_state(),
349            UpdateFlags::UPDATE_NONE,
350        ));
351        assert!(world.set_block(
352            right.east(),
353            vanilla_blocks::STONE.default_state(),
354            UpdateFlags::UPDATE_NONE,
355        ));
356        let left_state = vanilla_blocks::TRIPWIRE_HOOK
357            .default_state()
358            .set_value(HORIZONTAL_FACING, Direction::East);
359        let right_state = vanilla_blocks::TRIPWIRE_HOOK
360            .default_state()
361            .set_value(HORIZONTAL_FACING, Direction::West);
362        assert!(world.set_block(left, left_state, UpdateFlags::UPDATE_NONE));
363        assert!(world.set_block(right, right_state, UpdateFlags::UPDATE_NONE));
364        for x in 6..=8 {
365            assert!(world.set_block(
366                BlockPos::new(x, 64, 8),
367                vanilla_blocks::TRIPWIRE.default_state(),
368                UpdateFlags::UPDATE_NONE,
369            ));
370        }
371
372        TripWireHookBlock::calculate_state(&world, left, left_state, false, false, -1, None);
373        assert!(world.get_block_state(left).get_value(ATTACHED));
374        assert!(world.get_block_state(right).get_value(ATTACHED));
375
376        let powered_wire = world
377            .get_block_state(left.relative_n(Direction::East, 2))
378            .set_value(POWERED, true);
379        TripWireHookBlock::calculate_state(
380            &world,
381            left,
382            world.get_block_state(left),
383            false,
384            true,
385            2,
386            Some(powered_wire),
387        );
388        assert!(world.get_block_state(left).get_value(POWERED));
389        assert!(world.get_block_state(right).get_value(POWERED));
390
391        TripWireHookBlock::calculate_state(
392            &world,
393            left,
394            world.get_block_state(left),
395            false,
396            true,
397            2,
398            Some(powered_wire.set_value(DISARMED, true)),
399        );
400        let disarmed_hook = world.get_block_state(left);
401        assert!(!disarmed_hook.get_value(ATTACHED));
402        assert!(!disarmed_hook.get_value(POWERED));
403    }
404}