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::{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#[block_behavior]
23pub struct TripWireHookBlock {
24 block: BlockRef,
25}
26
27impl TripWireHookBlock {
28 #[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 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}