1use 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#[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 #[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 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}