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
235    /// Sets the text for a side.
236    pub fn set_text(&self, text: SignText, front: bool) {
237        let mut sign = self.sign.lock();
238        if front {
239            sign.front_text = text;
240        } else {
241            sign.back_text = text;
242        }
243    }
244}
245
246impl BlockEntity for SignBlockEntity {
247    fn base(&self) -> &BlockEntityBase {
248        &self.base
249    }
250
251    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
252        // Convert to NbtCompound view for accessing methods
253        let nbt_view: BorrowedNbtCompoundView<'_, '_> = nbt.into();
254        let mut sign = self.sign.lock();
255
256        // Load front text
257        if let Some(front_nbt) = nbt_view.compound("front_text") {
258            sign.front_text.load(front_nbt);
259        }
260
261        // Load back text
262        if let Some(back_nbt) = nbt_view.compound("back_text") {
263            sign.back_text.load(back_nbt);
264        }
265
266        // Load waxed state
267        if let Some(waxed) = nbt_view.byte("is_waxed") {
268            sign.is_waxed = waxed != 0;
269        }
270    }
271
272    fn save_additional(&self, nbt: &mut NbtCompound) {
273        let sign = self.sign.lock();
274        // Save front text
275        let mut front_nbt = NbtCompound::new();
276        sign.front_text.save(&mut front_nbt);
277        nbt.insert("front_text", front_nbt);
278
279        // Save back text
280        let mut back_nbt = NbtCompound::new();
281        sign.back_text.save(&mut back_nbt);
282        nbt.insert("back_text", back_nbt);
283
284        // Save waxed state
285        nbt.insert("is_waxed", i8::from(sign.is_waxed));
286    }
287
288    fn get_update_tag(&self) -> Option<NbtCompound> {
289        // Send full sign data to client
290        let mut nbt = NbtCompound::new();
291        self.save_additional(&mut nbt);
292        Some(nbt)
293    }
294
295    fn tick(&self, world: &Arc<World>) {
296        // Clear the edit lock if the editing player is too far away or gone
297        let editor_uuid = self.sign.lock().player_who_may_edit;
298        let Some(editor_uuid) = editor_uuid else {
299            return;
300        };
301        let should_clear = world
302            .players
303            .get_by_uuid(&editor_uuid)
304            .is_none_or(|player| {
305                let pos = self.get_block_pos();
306                let player_pos = player.position();
307                let dx = player_pos.x - f64::from(pos.0.x) - 0.5;
308                let dy = player_pos.y - f64::from(pos.0.y) - 0.5;
309                let dz = player_pos.z - f64::from(pos.0.z) - 0.5;
310                let distance_sq = dx * dx + dy * dy + dz * dz;
311                distance_sq > MAX_EDIT_DISTANCE * MAX_EDIT_DISTANCE
312            });
313
314        if should_clear {
315            let mut sign = self.sign.lock();
316            if sign.player_who_may_edit == Some(editor_uuid) {
317                sign.player_who_may_edit = None;
318            }
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use std::{array, io::Cursor, sync::Arc};
326
327    use simdnbt::borrow::read_tag;
328    use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
329    use steel_registry::{init_vanilla_registry, vanilla_blocks};
330    use steel_utils::BlockPos;
331    use text_components::{Modifier as _, TextComponent};
332    use uuid::Uuid;
333
334    use super::{SignBlockEntity, SignText};
335    use crate::block_entity::BlockEntity as _;
336    use crate::test_support::fresh_test_world;
337
338    #[test]
339    fn plain_sign_lines_save_as_a_string_list() {
340        let mut text = SignText::new();
341        text.messages = array::from_fn(|index| TextComponent::plain(index.to_string()));
342
343        let mut nbt = NbtCompound::new();
344        text.save(&mut nbt);
345
346        assert_eq!(
347            nbt.get("messages"),
348            Some(&NbtTag::List(NbtList::String(vec![
349                "0".into(),
350                "1".into(),
351                "2".into(),
352                "3".into(),
353            ])))
354        );
355    }
356
357    #[test]
358    fn mixed_sign_lines_round_trip_through_the_component_codec() {
359        let mut expected = SignText::new();
360        expected.messages[0] = TextComponent::plain("plain");
361        expected.messages[1] = TextComponent::plain("styled").bold(true);
362
363        let mut nbt = NbtCompound::new();
364        expected.save(&mut nbt);
365        assert!(matches!(
366            nbt.get("messages"),
367            Some(NbtTag::List(NbtList::Compound(_)))
368        ));
369
370        let mut bytes = Vec::new();
371        NbtTag::Compound(nbt).write(&mut bytes);
372        let borrowed = read_tag(&mut Cursor::new(bytes.as_slice()))
373            .expect("saved sign text should be valid NBT");
374        let borrowed_tag = borrowed.as_tag();
375        let compound = borrowed_tag
376            .compound()
377            .expect("saved sign text should be a compound");
378
379        let mut decoded = SignText::new();
380        decoded.load(compound);
381
382        assert_eq!(decoded.messages, expected.messages);
383        assert_eq!(decoded.color, expected.color);
384        assert_eq!(decoded.has_glowing_text, expected.has_glowing_text);
385    }
386
387    #[test]
388    fn sign_tick_releases_state_before_player_lookup_and_editor_clear() {
389        init_vanilla_registry();
390        let world = fresh_test_world("sign_editor_clear");
391        let sign = SignBlockEntity::new(
392            Arc::downgrade(&world),
393            BlockPos::new(8, 64, 8),
394            vanilla_blocks::OAK_SIGN.default_state(),
395        );
396        sign.set_player_who_may_edit(Some(Uuid::from_u128(1)));
397
398        sign.tick(&world);
399        assert_eq!(sign.get_player_who_may_edit(), None);
400    }
401}