Skip to main content

steel_core/block_entity/entities/
sign.rs

1//! Sign block entity implementation.
2//!
3//! Signs store text on both front and back sides, along with color and glow
4//! information.
5
6use std::array;
7use std::sync::{Arc, Weak};
8
9use simdnbt::borrow::{
10    BaseNbtCompound as BorrowedNbtCompound, NbtCompound as BorrowedNbtCompoundView,
11};
12use simdnbt::owned::{NbtCompound, NbtList};
13use steel_registry::block_entity_type::BlockEntityTypeRef;
14use steel_registry::{DyeColor, vanilla_block_entity_types};
15use steel_utils::{BlockPos, BlockStateId, DowncastType, DowncastTypeKey, locks::SyncMutex};
16use text_components::{TextComponent, content::Content};
17use uuid::Uuid;
18
19use crate::block_entity::{BlockEntity, BlockEntityBase};
20use crate::entity::Entity;
21use crate::world::World;
22
23/// Maximum distance (in blocks) a player can be from a sign while editing.
24/// If they move further away, the edit lock is released.
25const MAX_EDIT_DISTANCE: f64 = 4.0;
26
27/// Number of text lines on each side of a sign.
28pub const SIGN_LINES: usize = 4;
29
30/// Text and styling for one side of a sign.
31#[derive(Debug, Clone)]
32pub struct SignText {
33    /// The 4 lines of text (raw, unfiltered).
34    pub messages: [TextComponent; SIGN_LINES],
35    /// Text color (dye color applied to the sign).
36    pub color: DyeColor,
37    /// Whether the text has a glowing effect (from glow ink sac).
38    pub has_glowing_text: bool,
39}
40
41impl Default for SignText {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl SignText {
48    /// Creates a new empty sign text with default color (black) and no glow.
49    #[must_use]
50    pub fn new() -> Self {
51        Self {
52            messages: array::from_fn(|_| TextComponent::new()),
53            color: DyeColor::Black,
54            has_glowing_text: false,
55        }
56    }
57
58    /// Gets a message line by index.
59    #[must_use]
60    pub fn get_message(&self, index: usize) -> Option<&TextComponent> {
61        self.messages.get(index)
62    }
63
64    /// Sets a message line by index.
65    pub fn set_message(&mut self, index: usize, message: TextComponent) {
66        if index < SIGN_LINES {
67            self.messages[index] = message;
68        }
69    }
70
71    /// Checks if any line has text content.
72    #[must_use]
73    pub fn has_message(&self) -> bool {
74        self.messages.iter().any(|msg| {
75            // Check if the text component has any actual content
76            match &msg.content {
77                Content::Text { text } => !text.is_empty(),
78                _ => true, // Translations, etc. count as having a message
79            }
80        })
81    }
82
83    /// Loads sign text from borrowed NBT.
84    pub fn load(&mut self, nbt: BorrowedNbtCompoundView<'_, '_>) {
85        if let Some(messages) = nbt.list("messages") {
86            let tags = messages.to_owned().as_nbt_tags();
87            let messages = tags
88                .iter()
89                .map(TextComponent::from_nbt)
90                .collect::<Option<Vec<_>>>();
91            if let Some(messages) = messages
92                && let Ok(messages) = <[TextComponent; SIGN_LINES]>::try_from(messages)
93            {
94                self.messages = messages;
95            }
96        }
97
98        // Load color
99        if let Some(color_str) = nbt.string("color") {
100            self.color =
101                DyeColor::from_serialized_name(&color_str.to_str()).unwrap_or(DyeColor::Black);
102        }
103
104        // Load glow
105        if let Some(glow) = nbt.byte("has_glowing_text") {
106            self.has_glowing_text = glow != 0;
107        }
108    }
109
110    /// Saves sign text to NBT.
111    pub fn save(&self, nbt: &mut NbtCompound) {
112        nbt.insert(
113            "messages",
114            NbtList::from(
115                self.messages
116                    .iter()
117                    .map(TextComponent::to_codec_nbt)
118                    .collect::<Vec<_>>(),
119            ),
120        );
121
122        // Save color
123        nbt.insert("color", self.color.serialized_name());
124
125        // Save glow
126        nbt.insert("has_glowing_text", i8::from(self.has_glowing_text));
127    }
128}
129
130/// Sign block entity.
131///
132/// Stores text on both front and back sides of the sign.
133pub struct SignBlockEntity {
134    base: BlockEntityBase,
135    sign: SyncMutex<SignState>,
136}
137
138struct SignState {
139    /// Text on the front side.
140    front_text: SignText,
141    /// Text on the back side.
142    back_text: SignText,
143    /// Whether the sign is waxed (prevents editing).
144    is_waxed: bool,
145    /// UUID of the player currently allowed to edit this sign.
146    /// Used to prevent multiple players from editing simultaneously.
147    player_who_may_edit: Option<Uuid>,
148}
149
150// SAFETY: This key identifies Steel's shared sign implementation for both sign
151// registry entries, rather than either registry entry itself.
152unsafe impl DowncastType for SignBlockEntity {
153    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/sign");
154}
155
156impl SignBlockEntity {
157    /// Creates a new sign block entity.
158    #[must_use]
159    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
160        Self::with_type(level, &vanilla_block_entity_types::SIGN, pos, state)
161    }
162
163    /// Creates a new hanging sign block entity.
164    #[must_use]
165    pub fn new_hanging(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
166        Self::with_type(level, &vanilla_block_entity_types::HANGING_SIGN, pos, state)
167    }
168
169    /// Creates a sign block entity with a specific type.
170    #[must_use]
171    pub fn with_type(
172        level: Weak<World>,
173        block_entity_type: BlockEntityTypeRef,
174        pos: BlockPos,
175        state: BlockStateId,
176    ) -> Self {
177        Self {
178            base: BlockEntityBase::new(block_entity_type, level, pos, state),
179            sign: SyncMutex::new(SignState {
180                front_text: SignText::new(),
181                back_text: SignText::new(),
182                is_waxed: false,
183                player_who_may_edit: None,
184            }),
185        }
186    }
187
188    /// Gets the UUID of the player currently allowed to edit this sign.
189    #[must_use]
190    pub fn get_player_who_may_edit(&self) -> Option<Uuid> {
191        self.sign.lock().player_who_may_edit
192    }
193
194    /// Sets the player allowed to edit this sign.
195    pub fn set_player_who_may_edit(&self, player: Option<Uuid>) {
196        self.sign.lock().player_who_may_edit = player;
197    }
198
199    /// Checks if another player (not the given one) is currently editing this sign.
200    #[must_use]
201    pub fn is_other_player_editing(&self, player_uuid: Uuid) -> bool {
202        self.sign
203            .lock()
204            .player_who_may_edit
205            .is_some_and(|editor| editor != player_uuid)
206    }
207
208    /// Gets the text for a side.
209    #[must_use]
210    pub fn get_text(&self, front: bool) -> SignText {
211        let sign = self.sign.lock();
212        if front {
213            sign.front_text.clone()
214        } else {
215            sign.back_text.clone()
216        }
217    }
218
219    /// Returns whether this sign is waxed.
220    #[must_use]
221    pub fn is_waxed(&self) -> bool {
222        self.sign.lock().is_waxed
223    }
224
225    /// Makes this sign waxed, returning whether its state changed.
226    pub fn wax(&self) -> bool {
227        let mut sign = self.sign.lock();
228        if sign.is_waxed {
229            return false;
230        }
231        sign.is_waxed = true;
232        true
233    }
234    /// Sets whether a side's text glows, returning whether its state changed or not :3.
235    ///
236    /// Mirrors vanilla `SignBlockEntity.updateText`: returns false when the side
237    /// already has the requested glow state, so callers can skip consuming the item.
238    pub fn set_glowing(&self, front: bool, glowing: bool) -> bool {
239        let mut sign = self.sign.lock();
240        let text = if front {
241            &mut sign.front_text
242        } else {
243            &mut sign.back_text
244        };
245        if text.has_glowing_text == glowing {
246            return false;
247        }
248        text.has_glowing_text = glowing;
249        true
250    }
251
252    /// Sets the text for a side.
253    pub fn set_text(&self, text: SignText, front: bool) {
254        let mut sign = self.sign.lock();
255        if front {
256            sign.front_text = text;
257        } else {
258            sign.back_text = text;
259        }
260    }
261}
262
263impl BlockEntity for SignBlockEntity {
264    fn base(&self) -> &BlockEntityBase {
265        &self.base
266    }
267
268    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
269        // Convert to NbtCompound view for accessing methods
270        let nbt_view: BorrowedNbtCompoundView<'_, '_> = nbt.into();
271        let mut sign = self.sign.lock();
272
273        // Load front text
274        if let Some(front_nbt) = nbt_view.compound("front_text") {
275            sign.front_text.load(front_nbt);
276        }
277
278        // Load back text
279        if let Some(back_nbt) = nbt_view.compound("back_text") {
280            sign.back_text.load(back_nbt);
281        }
282
283        // Load waxed state
284        if let Some(waxed) = nbt_view.byte("is_waxed") {
285            sign.is_waxed = waxed != 0;
286        }
287    }
288
289    fn save_additional(&self, nbt: &mut NbtCompound) {
290        let sign = self.sign.lock();
291        // Save front text
292        let mut front_nbt = NbtCompound::new();
293        sign.front_text.save(&mut front_nbt);
294        nbt.insert("front_text", front_nbt);
295
296        // Save back text
297        let mut back_nbt = NbtCompound::new();
298        sign.back_text.save(&mut back_nbt);
299        nbt.insert("back_text", back_nbt);
300
301        // Save waxed state
302        nbt.insert("is_waxed", i8::from(sign.is_waxed));
303    }
304
305    fn get_update_tag(&self) -> Option<NbtCompound> {
306        // Send full sign data to client
307        let mut nbt = NbtCompound::new();
308        self.save_additional(&mut nbt);
309        Some(nbt)
310    }
311
312    fn tick(&self, world: &Arc<World>) {
313        // Clear the edit lock if the editing player is too far away or gone
314        let editor_uuid = self.sign.lock().player_who_may_edit;
315        let Some(editor_uuid) = editor_uuid else {
316            return;
317        };
318        let should_clear = world
319            .players
320            .get_by_uuid(&editor_uuid)
321            .is_none_or(|player| {
322                let pos = self.get_block_pos();
323                let player_pos = player.position();
324                let dx = player_pos.x - f64::from(pos.0.x) - 0.5;
325                let dy = player_pos.y - f64::from(pos.0.y) - 0.5;
326                let dz = player_pos.z - f64::from(pos.0.z) - 0.5;
327                let distance_sq = dx * dx + dy * dy + dz * dz;
328                distance_sq > MAX_EDIT_DISTANCE * MAX_EDIT_DISTANCE
329            });
330
331        if should_clear {
332            let mut sign = self.sign.lock();
333            if sign.player_who_may_edit == Some(editor_uuid) {
334                sign.player_who_may_edit = None;
335            }
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use std::{array, io::Cursor, sync::Arc};
343
344    use simdnbt::borrow::read_tag;
345    use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
346    use steel_registry::{init_vanilla_registry, vanilla_blocks};
347    use steel_utils::BlockPos;
348    use text_components::{Modifier as _, TextComponent};
349    use uuid::Uuid;
350
351    use super::{SignBlockEntity, SignText};
352    use crate::block_entity::BlockEntity as _;
353    use crate::test_support::fresh_test_world;
354
355    #[test]
356    fn plain_sign_lines_save_as_a_string_list() {
357        let mut text = SignText::new();
358        text.messages = array::from_fn(|index| TextComponent::plain(index.to_string()));
359
360        let mut nbt = NbtCompound::new();
361        text.save(&mut nbt);
362
363        assert_eq!(
364            nbt.get("messages"),
365            Some(&NbtTag::List(NbtList::String(vec![
366                "0".into(),
367                "1".into(),
368                "2".into(),
369                "3".into(),
370            ])))
371        );
372    }
373
374    #[test]
375    fn mixed_sign_lines_round_trip_through_the_component_codec() {
376        let mut expected = SignText::new();
377        expected.messages[0] = TextComponent::plain("plain");
378        expected.messages[1] = TextComponent::plain("styled").bold(true);
379
380        let mut nbt = NbtCompound::new();
381        expected.save(&mut nbt);
382        assert!(matches!(
383            nbt.get("messages"),
384            Some(NbtTag::List(NbtList::Compound(_)))
385        ));
386
387        let mut bytes = Vec::new();
388        NbtTag::Compound(nbt).write(&mut bytes);
389        let borrowed = read_tag(&mut Cursor::new(bytes.as_slice()))
390            .expect("saved sign text should be valid NBT");
391        let borrowed_tag = borrowed.as_tag();
392        let compound = borrowed_tag
393            .compound()
394            .expect("saved sign text should be a compound");
395
396        let mut decoded = SignText::new();
397        decoded.load(compound);
398
399        assert_eq!(decoded.messages, expected.messages);
400        assert_eq!(decoded.color, expected.color);
401        assert_eq!(decoded.has_glowing_text, expected.has_glowing_text);
402    }
403
404    #[test]
405    fn sign_tick_releases_state_before_player_lookup_and_editor_clear() {
406        init_vanilla_registry();
407        let world = fresh_test_world("sign_editor_clear");
408        let sign = SignBlockEntity::new(
409            Arc::downgrade(&world),
410            BlockPos::new(8, 64, 8),
411            vanilla_blocks::OAK_SIGN.default_state(),
412        );
413        sign.set_player_who_may_edit(Some(Uuid::from_u128(1)));
414
415        sign.tick(&world);
416        assert_eq!(sign.get_player_who_may_edit(), None);
417    }
418}