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