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::data_components::vanilla_components::{
15    SIGN_TEXT_BACK, SIGN_TEXT_FRONT, SignText as SignTextComponent, WAXED,
16};
17use steel_registry::sound_event::SoundEventRef;
18use steel_registry::sound_events::{
19    BLOCK_HANGING_SIGN_WAXED_INTERACT_FAIL, BLOCK_SIGN_WAXED_INTERACT_FAIL,
20};
21use steel_registry::{DyeColor, vanilla_block_entity_types};
22use steel_utils::nbt::NbtNumeric as _;
23use steel_utils::nbt::NbtValueInput as _;
24use steel_utils::{
25    BlockPos, BlockStateId, DowncastType, DowncastTypeKey, locks::SyncMutex, types::SignTextSlot,
26};
27use text_components::{TextComponent, content::Content};
28use uuid::Uuid;
29
30use crate::block_entity::{BlockEntity, BlockEntityBase, ImplicitComponentGetter};
31use crate::entity::Entity;
32use crate::world::World;
33
34/// Maximum distance (in blocks) a player can be from a sign while editing.
35/// If they move further away, the edit lock is released.
36const MAX_EDIT_DISTANCE: f64 = 4.0;
37
38/// Number of text lines on each side of a sign.
39pub const SIGN_LINES: usize = 4;
40
41/// Text and styling for one side of a sign.
42#[derive(Debug, Clone)]
43pub struct SignText {
44    /// The 4 lines of text (raw, unfiltered).
45    pub messages: [TextComponent; SIGN_LINES],
46    /// Text color (dye color applied to the sign).
47    pub color: DyeColor,
48    /// Whether the text has a glowing effect (from glow ink sac).
49    pub has_glowing_text: bool,
50}
51
52impl Default for SignText {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl SignText {
59    /// Creates a new empty sign text with default color (black) and no glow.
60    #[must_use]
61    pub fn new() -> Self {
62        Self {
63            messages: array::from_fn(|_| TextComponent::new()),
64            color: DyeColor::Black,
65            has_glowing_text: false,
66        }
67    }
68
69    /// Gets a message line by index.
70    #[must_use]
71    pub fn get_message(&self, index: usize) -> Option<&TextComponent> {
72        self.messages.get(index)
73    }
74
75    /// Sets a message line by index.
76    pub fn set_message(&mut self, index: usize, message: TextComponent) {
77        if index < SIGN_LINES {
78            self.messages[index] = message;
79        }
80    }
81
82    /// Checks if any line has text content.
83    #[must_use]
84    pub fn has_message(&self) -> bool {
85        self.messages.iter().any(|msg| {
86            // Check if the text component has any actual content
87            match &msg.content {
88                Content::Text { text } => !text.is_empty(),
89                _ => true, // Translations, etc. count as having a message
90            }
91        })
92    }
93
94    /// Builds sign text from an item's `sign_text_front`/`sign_text_back`
95    /// component. Filtered messages are dropped: Steel has no text filtering.
96    fn from_component(component: Option<&SignTextComponent>) -> Self {
97        component.map_or_else(Self::new, |component| Self {
98            messages: component.messages.clone(),
99            color: component.color,
100            has_glowing_text: component.has_glowing_text,
101        })
102    }
103
104    /// Returns whether the sign editor can edit every line, i.e. all lines are
105    /// plain text. Styling is ignored; translations, scores, etc. are not editable.
106    #[must_use]
107    pub fn has_editable_text(&self) -> bool {
108        self.messages
109            .iter()
110            .all(|msg| matches!(msg.content, Content::Text { .. }))
111    }
112
113    /// Loads sign text from borrowed NBT.
114    pub fn load(&mut self, nbt: BorrowedNbtCompoundView<'_, '_>) {
115        if let Some(messages) = nbt.list("messages") {
116            let tags = messages.to_owned().as_nbt_tags();
117            let messages = tags
118                .iter()
119                .map(TextComponent::from_nbt)
120                .collect::<Option<Vec<_>>>();
121            if let Some(messages) = messages
122                && let Ok(messages) = <[TextComponent; SIGN_LINES]>::try_from(messages)
123            {
124                self.messages = messages;
125            }
126        }
127
128        // Load color
129        if let Some(color_str) = nbt.string("color") {
130            self.color =
131                DyeColor::from_serialized_name(&color_str.to_str()).unwrap_or(DyeColor::Black);
132        }
133
134        // Load glow
135        if let Some(glow) = nbt.get("has_glowing_text").and_then(|tag| tag.codec_bool()) {
136            self.has_glowing_text = glow;
137        }
138    }
139
140    /// Saves sign text to NBT.
141    pub fn save(&self, nbt: &mut NbtCompound) {
142        nbt.insert(
143            "messages",
144            NbtList::from(
145                self.messages
146                    .iter()
147                    .map(TextComponent::to_codec_nbt)
148                    .collect::<Vec<_>>(),
149            ),
150        );
151
152        // Save color
153        nbt.insert("color", self.color.serialized_name());
154
155        // Save glow
156        nbt.insert("has_glowing_text", i8::from(self.has_glowing_text));
157    }
158}
159
160/// Sign block entity.
161///
162/// Stores text on both front and back sides of the sign.
163pub struct SignBlockEntity {
164    base: BlockEntityBase,
165    sign: SyncMutex<SignState>,
166}
167
168struct SignState {
169    /// Text on the front side.
170    front_text: SignText,
171    /// Text on the back side.
172    back_text: SignText,
173    /// Whether the sign is waxed (prevents editing).
174    is_waxed: bool,
175    /// UUID of the player currently allowed to edit this sign.
176    /// Used to prevent multiple players from editing simultaneously.
177    player_who_may_edit: Option<Uuid>,
178}
179
180// SAFETY: This key identifies Steel's shared sign implementation for both sign
181// registry entries, rather than either registry entry itself.
182unsafe impl DowncastType for SignBlockEntity {
183    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/sign");
184}
185
186impl SignBlockEntity {
187    /// Creates a new sign block entity.
188    #[must_use]
189    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
190        Self::with_type(level, &vanilla_block_entity_types::SIGN, pos, state)
191    }
192
193    /// Creates a new hanging sign block entity.
194    #[must_use]
195    pub fn new_hanging(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
196        Self::with_type(level, &vanilla_block_entity_types::HANGING_SIGN, pos, state)
197    }
198
199    /// Creates a sign block entity with a specific type.
200    #[must_use]
201    pub fn with_type(
202        level: Weak<World>,
203        block_entity_type: BlockEntityTypeRef,
204        pos: BlockPos,
205        state: BlockStateId,
206    ) -> Self {
207        Self {
208            base: BlockEntityBase::new(block_entity_type, level, pos, state),
209            sign: SyncMutex::new(SignState {
210                front_text: SignText::new(),
211                back_text: SignText::new(),
212                is_waxed: false,
213                player_who_may_edit: None,
214            }),
215        }
216    }
217
218    /// Gets the UUID of the player currently allowed to edit this sign.
219    #[must_use]
220    pub fn get_player_who_may_edit(&self) -> Option<Uuid> {
221        self.sign.lock().player_who_may_edit
222    }
223
224    /// Sets the player allowed to edit this sign.
225    pub fn set_player_who_may_edit(&self, player: Option<Uuid>) {
226        self.sign.lock().player_who_may_edit = player;
227    }
228
229    /// Checks if another player (not the given one) is currently editing this sign.
230    #[must_use]
231    pub fn is_other_player_editing(&self, player_uuid: Uuid) -> bool {
232        self.sign
233            .lock()
234            .player_who_may_edit
235            .is_some_and(|editor| editor != player_uuid)
236    }
237
238    /// Gets the text for a side.
239    #[must_use]
240    pub fn get_text(&self, slot: SignTextSlot) -> SignText {
241        let sign = self.sign.lock();
242        if slot.is_front() {
243            sign.front_text.clone()
244        } else {
245            sign.back_text.clone()
246        }
247    }
248
249    /// Returns whether this sign is waxed.
250    #[must_use]
251    pub fn is_waxed(&self) -> bool {
252        self.sign.lock().is_waxed
253    }
254
255    /// Makes this sign waxed, returning whether its state changed.
256    pub fn wax(&self) -> bool {
257        let mut sign = self.sign.lock();
258        if sign.is_waxed {
259            return false;
260        }
261        sign.is_waxed = true;
262        true
263    }
264    /// Sets whether a side's text glows, returning whether its state changed.
265    pub fn set_glowing(&self, slot: SignTextSlot, glowing: bool) -> bool {
266        let mut sign = self.sign.lock();
267        let text = if slot.is_front() {
268            &mut sign.front_text
269        } else {
270            &mut sign.back_text
271        };
272        if text.has_glowing_text == glowing {
273            return false;
274        }
275        text.has_glowing_text = glowing;
276        true
277    }
278
279    /// Sets the text color for a side, returning whether its state changed.
280    pub fn set_color(&self, slot: SignTextSlot, color: DyeColor) -> bool {
281        let mut sign = self.sign.lock();
282        let text = if slot.is_front() {
283            &mut sign.front_text
284        } else {
285            &mut sign.back_text
286        };
287        if text.color == color {
288            return false;
289        }
290        text.color = color;
291        true
292    }
293
294    /// Returns the sound event to play when a player fails to interact with this waxed sign.
295    #[must_use]
296    pub fn sign_interaction_failed_sound_event(&self) -> SoundEventRef {
297        if self.base.block_entity_type() == &vanilla_block_entity_types::HANGING_SIGN {
298            &BLOCK_HANGING_SIGN_WAXED_INTERACT_FAIL
299        } else {
300            &BLOCK_SIGN_WAXED_INTERACT_FAIL
301        }
302    }
303
304    /// Sets the text for a side.
305    pub fn set_text(&self, text: SignText, slot: SignTextSlot) {
306        let mut sign = self.sign.lock();
307        if slot.is_front() {
308            sign.front_text = text;
309        } else {
310            sign.back_text = text;
311        }
312    }
313}
314
315impl BlockEntity for SignBlockEntity {
316    fn base(&self) -> &BlockEntityBase {
317        &self.base
318    }
319
320    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
321        // Convert to NbtCompound view for accessing methods
322        let nbt_view: BorrowedNbtCompoundView<'_, '_> = nbt.into();
323        let mut sign = self.sign.lock();
324
325        // Load front text
326        if let Some(front_nbt) = nbt_view.compound("front_text") {
327            sign.front_text.load(front_nbt);
328        }
329
330        // Load back text
331        if let Some(back_nbt) = nbt_view.compound("back_text") {
332            sign.back_text.load(back_nbt);
333        }
334
335        // Load waxed state
336        if let Some(waxed) = nbt_view.byte_value("is_waxed") {
337            sign.is_waxed = waxed != 0;
338        }
339    }
340
341    fn apply_implicit_components(&self, components: &mut ImplicitComponentGetter<'_>) {
342        let mut sign = self.sign.lock();
343        sign.front_text = SignText::from_component(components.get(SIGN_TEXT_FRONT).as_ref());
344        sign.back_text = SignText::from_component(components.get(SIGN_TEXT_BACK).as_ref());
345        sign.is_waxed = components.get(WAXED).is_some();
346    }
347
348    fn save_additional(&self, nbt: &mut NbtCompound) {
349        let sign = self.sign.lock();
350        // Save front text
351        let mut front_nbt = NbtCompound::new();
352        sign.front_text.save(&mut front_nbt);
353        nbt.insert("front_text", front_nbt);
354
355        // Save back text
356        let mut back_nbt = NbtCompound::new();
357        sign.back_text.save(&mut back_nbt);
358        nbt.insert("back_text", back_nbt);
359
360        // Save waxed state
361        nbt.insert("is_waxed", i8::from(sign.is_waxed));
362    }
363
364    fn get_update_tag(&self) -> Option<NbtCompound> {
365        // Send full sign data to client
366        let mut nbt = NbtCompound::new();
367        self.save_additional(&mut nbt);
368        Some(nbt)
369    }
370
371    fn tick(&self, world: &Arc<World>) {
372        // Clear the edit lock if the editing player is too far away or gone
373        let editor_uuid = self.sign.lock().player_who_may_edit;
374        let Some(editor_uuid) = editor_uuid else {
375            return;
376        };
377        let should_clear = world
378            .players
379            .get_by_uuid(&editor_uuid)
380            .is_none_or(|player| {
381                let pos = self.get_block_pos();
382                let player_pos = player.position();
383                let dx = player_pos.x - f64::from(pos.0.x) - 0.5;
384                let dy = player_pos.y - f64::from(pos.0.y) - 0.5;
385                let dz = player_pos.z - f64::from(pos.0.z) - 0.5;
386                let distance_sq = dx * dx + dy * dy + dz * dz;
387                distance_sq > MAX_EDIT_DISTANCE * MAX_EDIT_DISTANCE
388            });
389
390        if should_clear {
391            let mut sign = self.sign.lock();
392            if sign.player_who_may_edit == Some(editor_uuid) {
393                sign.player_who_may_edit = None;
394            }
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use std::{array, io::Cursor, sync::Arc};
402
403    use simdnbt::borrow::read_tag;
404    use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
405    use steel_registry::{
406        init_vanilla_registry, item_stack::ItemStack, vanilla_blocks, vanilla_items,
407    };
408    use steel_utils::{BlockPos, translations, types::SignTextSlot};
409    use text_components::{Modifier as _, TextComponent};
410    use uuid::Uuid;
411
412    use super::{DyeColor, SIGN_TEXT_FRONT, SignBlockEntity, SignText, SignTextComponent, WAXED};
413    use crate::block_entity::BlockEntity as _;
414    use crate::test_support::fresh_test_world;
415
416    #[test]
417    fn plain_sign_lines_save_as_a_string_list() {
418        let mut text = SignText::new();
419        text.messages = array::from_fn(|index| TextComponent::plain(index.to_string()));
420
421        let mut nbt = NbtCompound::new();
422        text.save(&mut nbt);
423
424        assert_eq!(
425            nbt.get("messages"),
426            Some(&NbtTag::List(NbtList::String(vec![
427                "0".into(),
428                "1".into(),
429                "2".into(),
430                "3".into(),
431            ])))
432        );
433    }
434
435    #[test]
436    fn mixed_sign_lines_round_trip_through_the_component_codec() {
437        let mut expected = SignText::new();
438        expected.messages[0] = TextComponent::plain("plain");
439        expected.messages[1] = TextComponent::plain("styled").bold(true);
440
441        let mut nbt = NbtCompound::new();
442        expected.save(&mut nbt);
443        assert!(matches!(
444            nbt.get("messages"),
445            Some(NbtTag::List(NbtList::Compound(_)))
446        ));
447
448        let mut bytes = Vec::new();
449        NbtTag::Compound(nbt).write(&mut bytes);
450        let borrowed = read_tag(&mut Cursor::new(bytes.as_slice()))
451            .expect("saved sign text should be valid NBT");
452        let borrowed_tag = borrowed.as_tag();
453        let compound = borrowed_tag
454            .compound()
455            .expect("saved sign text should be a compound");
456
457        let mut decoded = SignText::new();
458        decoded.load(compound);
459
460        assert_eq!(decoded.messages, expected.messages);
461        assert_eq!(decoded.color, expected.color);
462        assert_eq!(decoded.has_glowing_text, expected.has_glowing_text);
463    }
464
465    #[test]
466    fn styled_plain_text_is_editable_but_translations_are_not() {
467        let mut text = SignText::new();
468        text.messages[0] = TextComponent::plain("styled").bold(true);
469        assert!(text.has_editable_text());
470
471        text.messages[1] = TextComponent::translated(translations::BLOCK_MINECRAFT_STONE.msg());
472        assert!(!text.has_editable_text());
473    }
474
475    #[test]
476    fn placed_sign_takes_text_and_wax_from_item_components() {
477        init_vanilla_registry();
478        let world_fixture = fresh_test_world("sign_item_components");
479        let world = &world_fixture.world;
480        let pos = BlockPos::new(0, 64, 0);
481        let sign = SignBlockEntity::new(
482            Arc::downgrade(world),
483            pos,
484            vanilla_blocks::OAK_SIGN.default_state(),
485        );
486
487        let mut messages = array::from_fn(|_| TextComponent::new());
488        messages[0] = TextComponent::plain("front");
489        let mut item = ItemStack::new(&vanilla_items::OAK_SIGN);
490        item.set(
491            SIGN_TEXT_FRONT,
492            SignTextComponent::new(messages.clone(), None, DyeColor::Red, true),
493        );
494        item.set(WAXED, ());
495
496        sign.apply_components_from_item_stack(&item);
497
498        let front = sign.get_text(SignTextSlot::Front);
499        assert_eq!(front.messages, messages);
500        assert_eq!(front.color, DyeColor::Red);
501        assert!(front.has_glowing_text);
502        assert!(!sign.get_text(SignTextSlot::Back).has_message());
503        assert!(sign.is_waxed());
504        assert!(!sign.base().stored_components().has(SIGN_TEXT_FRONT));
505        assert!(!sign.base().stored_components().has(WAXED));
506    }
507
508    #[test]
509    fn sign_tick_releases_state_before_player_lookup_and_editor_clear() {
510        init_vanilla_registry();
511        let world_fixture = fresh_test_world("sign_editor_clear");
512        let world = &world_fixture.world;
513        let sign = SignBlockEntity::new(
514            Arc::downgrade(world),
515            BlockPos::new(8, 64, 8),
516            vanilla_blocks::OAK_SIGN.default_state(),
517        );
518        sign.set_player_who_may_edit(Some(Uuid::from_u128(1)));
519
520        sign.tick(world);
521        assert_eq!(sign.get_player_who_may_edit(), None);
522    }
523
524    #[test]
525    fn set_color_updates_color_and_reports_change() {
526        init_vanilla_registry();
527        let world_fixture = fresh_test_world("sign_set_color");
528        let world = &world_fixture.world;
529        let sign = SignBlockEntity::new(
530            Arc::downgrade(world),
531            BlockPos::new(0, 64, 0),
532            vanilla_blocks::OAK_SIGN.default_state(),
533        );
534
535        assert_eq!(sign.get_text(SignTextSlot::Front).color, DyeColor::Black);
536        assert!(sign.set_color(SignTextSlot::Front, DyeColor::Blue));
537        assert_eq!(sign.get_text(SignTextSlot::Front).color, DyeColor::Blue);
538        assert!(!sign.set_color(SignTextSlot::Front, DyeColor::Blue));
539
540        assert_eq!(sign.get_text(SignTextSlot::Back).color, DyeColor::Black);
541        assert!(sign.set_color(SignTextSlot::Back, DyeColor::Red));
542        assert_eq!(sign.get_text(SignTextSlot::Back).color, DyeColor::Red);
543    }
544}