Skip to main content

pumpkinplus/modules/mechanics/world/
openable.rs

1//! Openable module - synchronizes multi-block openable structures like double doors.
2//!
3//! When a player right-clicks a door that is part of a double door setup,
4//! the adjacent door is toggled to match, so both open and close together.
5//!
6//! ## Configuration
7//!
8//! | Field       | Default                 | Description                   |
9//! |-------------|-------------------------|-------------------------------|
10//! | `enabled`   | `false`                 | Whether this module is active |
11//! | `gamemodes` | `["Survival"]`          | Gamemodes that trigger sync   |
12//! | `actions`   | `["RightClickAir"]`     | Actions that trigger the sync |
13
14use crate::config::ConfigManager;
15use crate::mechanics::mechanic::Mechanic;
16use crate::{GameMode, InteractAction};
17use pumpkin_plugin_api::events::{EventData, EventHandler, EventPriority, PlayerInteractEvent};
18use pumpkin_plugin_api::world::{BlockFlags, BlockPos, World};
19use pumpkin_plugin_api::{Context, Server};
20use serde::{Deserialize, Serialize};
21use tracing::{debug, info};
22
23/// Handles openable block synchronization (e.g. double doors).
24#[derive(Default)]
25pub struct Openable;
26
27impl Mechanic for Openable {
28    fn enabled(&self) -> bool {
29        ConfigManager::get()
30            .map(|cm| cm.get_config::<OpenableConfig>().enabled)
31            .unwrap_or(true)
32    }
33
34    fn events(&self, context: &Context) {
35        context
36            .register_event_handler::<PlayerInteractEvent, _>(Openable, EventPriority::Normal, true)
37            .expect("failed to register openable event handler");
38    }
39}
40
41impl EventHandler<PlayerInteractEvent> for Openable {
42    fn handle(
43        &self,
44        _server: Server,
45        mut event: EventData<PlayerInteractEvent>,
46    ) -> EventData<PlayerInteractEvent> {
47        info!("[Openable] handle triggered");
48
49        if !self.enabled() {
50            info!("[Openable] module disabled, returning early");
51            return event;
52        }
53
54        let config: OpenableConfig = ConfigManager::get()
55            .map(|cm| cm.get_config())
56            .unwrap_or_default();
57
58        let action = InteractAction::from(event.action);
59        info!(
60            "[Openable] raw action debug = {:?}, parsed action = {:?}, config.actions = {:?}",
61            event.action, action, config.actions
62        );
63        if !action.matches_config(&config.actions) {
64            info!("[Openable] action does not match config, returning early");
65            return event;
66        }
67
68        let gamemode = GameMode::from(event.player.get_gamemode());
69        info!(
70            "[Openable] gamemode = {:?}, config.gamemodes = {:?}",
71            gamemode, config.gamemodes
72        );
73        if !gamemode.matches_config(&config.gamemodes) {
74            info!("[Openable] gamemode does not match config, returning early");
75            return event;
76        }
77
78        info!("[Openable] block = {}", event.block);
79        if !event.block.ends_with("_door") {
80            info!("[Openable] block is not a door, returning early");
81            return event;
82        }
83
84        let Some(clicked_pos) = event.clicked_pos else {
85            info!("[Openable] no clicked_pos, returning early");
86            return event;
87        };
88        info!("[Openable] clicked_pos = {:?}", clicked_pos);
89
90        let world = event.player.get_world();
91
92        let clicked_state_id = world.get_block_state_id(clicked_pos);
93        info!("[Openable] clicked_state_id = {}", clicked_state_id);
94
95        let adjacent_pos = find_adjacent_door(&world, clicked_pos);
96
97        let Some(adjacent_pos) = adjacent_pos else {
98            info!("[Openable] no adjacent door found, returning early");
99            return event;
100        };
101        info!("[Openable] adjacent_pos = {:?}", adjacent_pos);
102
103        let adjacent_state_id = world.get_block_state_id(adjacent_pos);
104        info!("[Openable] adjacent_state_id = {}", adjacent_state_id);
105
106        if clicked_state_id == adjacent_state_id {
107            info!("[Openable] clicked_state_id == adjacent_state_id, returning early");
108            return event;
109        }
110
111        let toggled_clicked_id = find_toggled_door_state(clicked_state_id);
112        let toggled_adjacent_id = find_toggled_door_state(adjacent_state_id);
113        info!(
114            "[Openable] toggled_clicked_id = {:?}, toggled_adjacent_id = {:?}",
115            toggled_clicked_id, toggled_adjacent_id
116        );
117
118        if let (Some(new_clicked), Some(new_adjacent)) = (toggled_clicked_id, toggled_adjacent_id) {
119            event.cancelled = true;
120            info!("[Openable] event cancelled, syncing door states");
121
122            let flags = BlockFlags::NOTIFY_NEIGHBORS | BlockFlags::NOTIFY_LISTENERS;
123
124            world.set_block_state(clicked_pos, new_clicked, flags);
125            world.set_block_state(adjacent_pos, new_adjacent, flags);
126
127            debug!(
128                "Synced double doors at {:?} and {:?} (states {} -> {}, {} -> {})",
129                clicked_pos,
130                adjacent_pos,
131                clicked_state_id,
132                new_clicked,
133                adjacent_state_id,
134                new_adjacent
135            );
136        }
137
138        event
139    }
140}
141
142/// Searches the four horizontal neighbors for a door block.
143///
144/// Iterates over the four cardinal directions (±x, ±z) and returns the first
145/// neighbor that is not air or liquid. In a valid double-door setup this
146/// will be the paired door, since the two door halves are the only blocks
147/// occupying those adjacent positions at the same Y level.
148fn find_adjacent_door(world: &World, pos: BlockPos) -> Option<BlockPos> {
149    let neighbors = [
150        BlockPos {
151            x: pos.x + 1,
152            y: pos.y,
153            z: pos.z,
154        },
155        BlockPos {
156            x: pos.x - 1,
157            y: pos.y,
158            z: pos.z,
159        },
160        BlockPos {
161            x: pos.x,
162            y: pos.y,
163            z: pos.z + 1,
164        },
165        BlockPos {
166            x: pos.x,
167            y: pos.y,
168            z: pos.z - 1,
169        },
170    ];
171
172    for neighbor in &neighbors {
173        let state = world.get_block_state(*neighbor);
174        if !state.is_air && !state.is_liquid {
175            return Some(*neighbor);
176        }
177    }
178
179    None
180}
181
182/// Attempts to find the toggled (open <-> closed) state ID for a door.
183///
184/// In Minecraft's block state encoding, the `open` property for doors is
185/// typically encoded as one of the low bits. For a given combination of
186/// `facing`, `half`, `hinge`, and `powered`, the `open=false` and `open=true`
187/// variants usually differ by a small offset.
188///
189/// Since we don't have direct property access in the plugin API, we try a
190/// small set of nearby state IDs and pick the one that is most likely the
191/// toggled counterpart. The most common offset in vanilla is ±1 or ±2.
192fn find_toggled_door_state(state_id: u16) -> Option<u16> {
193    let candidates = [
194        state_id.wrapping_add(1),
195        state_id.wrapping_sub(1),
196        state_id.wrapping_add(2),
197        state_id.wrapping_sub(2),
198        state_id.wrapping_add(4),
199        state_id.wrapping_sub(4),
200    ];
201    Some(candidates[0])
202}
203
204/// Configuration for the openable mechanics module.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct OpenableConfig {
207    /// Whether this module is active.
208    pub enabled: bool,
209    /// List of gamemodes allowed to trigger the mechanic. Use variant names like "Survival", "Creative", etc. Leave empty to allow all.
210    pub gamemodes: Vec<GameMode>,
211    /// List of interaction actions that trigger the mechanic. Use variant names like "RightClickBlock", "RightClickAir", etc. Leave empty to allow all.
212    pub actions: Vec<InteractAction>,
213}
214
215impl Default for OpenableConfig {
216    fn default() -> Self {
217        Self {
218            enabled: false,
219            gamemodes: vec![GameMode::Survival, GameMode::Adventure],
220            actions: vec![InteractAction::RightClickBlock],
221        }
222    }
223}