steel_core/block_entity/entities/
sign.rs1use 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
34const MAX_EDIT_DISTANCE: f64 = 4.0;
37
38pub const SIGN_LINES: usize = 4;
40
41#[derive(Debug, Clone)]
43pub struct SignText {
44 pub messages: [TextComponent; SIGN_LINES],
46 pub color: DyeColor,
48 pub has_glowing_text: bool,
50}
51
52impl Default for SignText {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl SignText {
59 #[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 #[must_use]
71 pub fn get_message(&self, index: usize) -> Option<&TextComponent> {
72 self.messages.get(index)
73 }
74
75 pub fn set_message(&mut self, index: usize, message: TextComponent) {
77 if index < SIGN_LINES {
78 self.messages[index] = message;
79 }
80 }
81
82 #[must_use]
84 pub fn has_message(&self) -> bool {
85 self.messages.iter().any(|msg| {
86 match &msg.content {
88 Content::Text { text } => !text.is_empty(),
89 _ => true, }
91 })
92 }
93
94 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 #[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 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 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 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 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 nbt.insert("color", self.color.serialized_name());
154
155 nbt.insert("has_glowing_text", i8::from(self.has_glowing_text));
157 }
158}
159
160pub struct SignBlockEntity {
164 base: BlockEntityBase,
165 sign: SyncMutex<SignState>,
166}
167
168struct SignState {
169 front_text: SignText,
171 back_text: SignText,
173 is_waxed: bool,
175 player_who_may_edit: Option<Uuid>,
178}
179
180unsafe impl DowncastType for SignBlockEntity {
183 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/sign");
184}
185
186impl SignBlockEntity {
187 #[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 #[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 #[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 #[must_use]
220 pub fn get_player_who_may_edit(&self) -> Option<Uuid> {
221 self.sign.lock().player_who_may_edit
222 }
223
224 pub fn set_player_who_may_edit(&self, player: Option<Uuid>) {
226 self.sign.lock().player_who_may_edit = player;
227 }
228
229 #[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 #[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 #[must_use]
251 pub fn is_waxed(&self) -> bool {
252 self.sign.lock().is_waxed
253 }
254
255 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 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 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 #[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 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 let nbt_view: BorrowedNbtCompoundView<'_, '_> = nbt.into();
323 let mut sign = self.sign.lock();
324
325 if let Some(front_nbt) = nbt_view.compound("front_text") {
327 sign.front_text.load(front_nbt);
328 }
329
330 if let Some(back_nbt) = nbt_view.compound("back_text") {
332 sign.back_text.load(back_nbt);
333 }
334
335 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 let mut front_nbt = NbtCompound::new();
352 sign.front_text.save(&mut front_nbt);
353 nbt.insert("front_text", front_nbt);
354
355 let mut back_nbt = NbtCompound::new();
357 sign.back_text.save(&mut back_nbt);
358 nbt.insert("back_text", back_nbt);
359
360 nbt.insert("is_waxed", i8::from(sign.is_waxed));
362 }
363
364 fn get_update_tag(&self) -> Option<NbtCompound> {
365 let mut nbt = NbtCompound::new();
367 self.save_additional(&mut nbt);
368 Some(nbt)
369 }
370
371 fn tick(&self, world: &Arc<World>) {
372 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}