steel_core/block_entity/entities/
jukebox.rs1use std::io::Cursor;
4use std::mem;
5use std::sync::{Arc, Weak};
6
7use glam::DVec3;
8use simdnbt::borrow::{
9 BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView,
10 read_compound as read_borrowed_compound,
11};
12use simdnbt::owned::NbtCompound;
13use steel_registry::blocks::block_state_ext::BlockStateExt as _;
14use steel_registry::blocks::properties::BlockStateProperties;
15use steel_registry::data_components::vanilla_components::JUKEBOX_PLAYABLE;
16use steel_registry::item_stack::ItemStack;
17use steel_registry::jukebox_song::JukeboxSongValue;
18use steel_registry::particle_type::ParticleData;
19use steel_registry::{
20 RegistryEntry, level_events, vanilla_block_entity_types, vanilla_game_events,
21 vanilla_particle_types,
22};
23use steel_utils::nbt::NbtValueInput as _;
24use steel_utils::nbt::{merge_nbt_compounds, nbt_compounds_equal};
25use steel_utils::types::UpdateFlags;
26use steel_utils::{
27 BlockPos, BlockStateId, DowncastType, DowncastTypeKey, TICKS_PER_SECOND, locks::SyncMutex,
28};
29
30use crate::block_entity::{BlockEntity, BlockEntityBase};
31use crate::world::World;
32use crate::world::game_event::GameEventContext;
33
34const RECORD_ITEM_TAG: &str = "RecordItem";
35const TICKS_SINCE_SONG_STARTED_TAG: &str = "ticks_since_song_started";
36const PLAY_EVENT_INTERVAL_TICKS: i64 = 20;
37const SONG_END_PADDING_TICKS: i32 = 20;
38const UNKNOWN_SONG_REGISTRY_ID: i32 = -1;
39const BLOCK_CENTER_OFFSET: f64 = 0.5;
40const ITEM_EJECTION_Y_OFFSET: f64 = 1.01;
41const ITEM_EJECTION_MAX_HORIZONTAL_OFFSET: f32 = 0.35;
42const NOTE_PARTICLE_Y_OFFSET: f32 = 1.2;
43const NOTE_PARTICLE_COLOR_VARIANTS: u8 = 4;
44const NOTE_PARTICLE_COLOR_DIVISOR: f32 = 24.0;
45
46struct JukeboxPlayback {
47 ticks_since_song_started: i64,
48}
49
50struct JukeboxState {
51 item: ItemStack,
52 playback: Option<JukeboxPlayback>,
53}
54
55pub struct JukeboxBlockEntity {
58 base: BlockEntityBase,
59 state: SyncMutex<JukeboxState>,
60}
61
62unsafe impl DowncastType for JukeboxBlockEntity {
64 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/jukebox");
65}
66
67impl JukeboxBlockEntity {
68 #[must_use]
70 pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
71 Self {
72 base: BlockEntityBase::new(&vanilla_block_entity_types::JUKEBOX, level, pos, state),
73 state: SyncMutex::new(JukeboxState {
74 item: ItemStack::empty(),
75 playback: None,
76 }),
77 }
78 }
79
80 fn song_value(item: &ItemStack) -> Option<&JukeboxSongValue> {
81 Some(item.get(JUKEBOX_PLAYABLE)?.song().value())
82 }
83
84 fn song_event_data(item: &ItemStack) -> i32 {
85 let Some(playable) = item.get(JUKEBOX_PLAYABLE) else {
86 return UNKNOWN_SONG_REGISTRY_ID;
87 };
88 let song = playable.song();
89 song.as_reference()
90 .and_then(RegistryEntry::try_id)
91 .and_then(|id| i32::try_from(id).ok())
92 .unwrap_or(UNKNOWN_SONG_REGISTRY_ID)
93 }
94
95 fn song_has_finished(song: &JukeboxSongValue, ticks_elapsed: i64) -> bool {
96 let length_in_ticks = (song.length_in_seconds * TICKS_PER_SECOND as f32).ceil() as i32;
99 ticks_elapsed >= i64::from(length_in_ticks.wrapping_add(SONG_END_PADDING_TICKS))
100 }
101
102 fn on_song_changed(&self, world: &Arc<World>) {
103 world.update_neighbors_at(self.get_block_pos(), self.get_block_state().get_block());
104 self.set_changed();
105 }
106
107 fn emit_stop_pair(&self, world: &Arc<World>) {
108 let pos = self.get_block_pos();
109 world.game_event(
110 &vanilla_game_events::JUKEBOX_STOP_PLAY,
111 pos,
112 &GameEventContext::new(None, Some(self.get_block_state())),
113 );
114 world.level_event(level_events::SOUND_STOP_JUKEBOX_SONG, pos, 0, None);
115 }
116
117 fn stop_playback(&self, world: Option<&Arc<World>>) {
118 let stopped = self.state.lock().playback.take().is_some();
119 if !stopped {
120 return;
121 }
122 let Some(world) = world else {
123 return;
124 };
125 self.emit_stop_pair(world);
126 self.on_song_changed(world);
127 }
128
129 fn notify_item_changed(&self, world: &Arc<World>, has_record: bool) {
130 let pos = self.get_block_pos();
131 let cached_state = self.get_block_state();
132 if world.get_block_state(pos) != cached_state {
133 return;
134 }
135
136 world.set_block(
137 pos,
138 cached_state.set_value(&BlockStateProperties::HAS_RECORD, has_record),
139 UpdateFlags::UPDATE_CLIENTS,
140 );
141 world.game_event(
142 &vanilla_game_events::BLOCK_CHANGE,
143 pos,
144 &GameEventContext::new(None, Some(self.get_block_state())),
145 );
146 }
147
148 pub fn set_the_item(&self, item: ItemStack) {
150 let has_record = !item.is_empty();
151 let has_song = Self::song_value(&item).is_some();
152 let song_event_data = Self::song_event_data(&item);
153 let world = self.get_level();
154 self.state.lock().item = item;
155 let Some(world) = world else {
156 return;
157 };
158
159 self.notify_item_changed(&world, has_record);
160 if has_record && has_song {
161 self.state.lock().playback = Some(JukeboxPlayback {
162 ticks_since_song_started: 0,
163 });
164 world.level_event(
165 level_events::SOUND_PLAY_JUKEBOX_SONG,
166 self.get_block_pos(),
167 song_event_data,
168 None,
169 );
170 self.on_song_changed(&world);
171 } else {
172 self.stop_playback(Some(&world));
173 }
174 }
175
176 pub fn pop_out_the_item(&self) {
178 let Some(world) = self.get_level() else {
179 return;
180 };
181 let item = {
182 let mut state = self.state.lock();
183 if state.item.is_empty() {
184 return;
185 }
186 mem::replace(&mut state.item, ItemStack::empty())
187 };
188
189 self.notify_item_changed(&world, false);
190 self.stop_playback(Some(&world));
191
192 let pos = self.get_block_pos();
193 let random_x = rand::random_range(
194 -ITEM_EJECTION_MAX_HORIZONTAL_OFFSET..ITEM_EJECTION_MAX_HORIZONTAL_OFFSET,
195 );
196 let random_z = rand::random_range(
197 -ITEM_EJECTION_MAX_HORIZONTAL_OFFSET..ITEM_EJECTION_MAX_HORIZONTAL_OFFSET,
198 );
199 let item_pos = DVec3::new(
200 f64::from(pos.x()) + BLOCK_CENTER_OFFSET + f64::from(random_x),
201 f64::from(pos.y()) + ITEM_EJECTION_Y_OFFSET,
202 f64::from(pos.z()) + BLOCK_CENTER_OFFSET + f64::from(random_z),
203 );
204 if let Some(entity) = world.spawn_item(item_pos, item) {
205 entity.set_default_pickup_delay();
206 }
207 self.on_song_changed(&world);
209 }
210
211 #[must_use]
213 pub fn is_record_playing(&self) -> bool {
214 self.state.lock().playback.is_some()
215 }
216
217 #[must_use]
219 pub fn analog_output_signal(&self) -> i32 {
220 let state = self.state.lock();
221 Self::song_value(&state.item).map_or(0, |song| song.comparator_output)
222 }
223
224 pub fn apply_item_block_entity_data(&self, payload: NbtCompound) -> bool {
229 let before = self.save_custom_only();
230 let mut merged = self.save_custom_only();
231 merge_nbt_compounds(&mut merged, &payload);
232 if nbt_compounds_equal(&before, &merged) {
233 return false;
234 }
235
236 let mut bytes = Vec::new();
237 merged.write(&mut bytes);
238 let Ok(borrowed) = read_borrowed_compound(&mut Cursor::new(bytes.as_slice())) else {
239 log::warn!(
240 "failed to reborrow item block-entity data for jukebox at {:?}",
241 self.get_block_pos()
242 );
243 return false;
244 };
245 self.load_additional(&borrowed);
246 self.set_changed();
247 true
248 }
249}
250
251enum TickAction {
252 Stop,
253 EmitPlayingEvent,
254 None,
255}
256
257impl BlockEntity for JukeboxBlockEntity {
258 fn base(&self) -> &BlockEntityBase {
259 &self.base
260 }
261
262 fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
263 let nbt: NbtCompoundView<'_, '_> = nbt.into();
264 let new_item = nbt
265 .compound(RECORD_ITEM_TAG)
266 .and_then(|item| ItemStack::from_borrowed_compound(&item))
267 .unwrap_or_else(ItemStack::empty);
268 let saved_ticks = nbt.long_value(TICKS_SINCE_SONG_STARTED_TAG);
269
270 let should_stop = {
271 let state = self.state.lock();
272 !state.item.is_empty()
273 && !ItemStack::is_same_item_same_components(&new_item, &state.item)
274 };
275 if should_stop {
276 let world = self.get_level();
277 self.stop_playback(world.as_ref());
278 }
279
280 let should_resume = saved_ticks.is_some_and(|ticks| {
281 Self::song_value(&new_item).is_some_and(|song| !Self::song_has_finished(song, ticks))
282 });
283 let mut state = self.state.lock();
284 state.item = new_item;
285 if should_resume {
286 state.playback = saved_ticks.map(|ticks| JukeboxPlayback {
287 ticks_since_song_started: ticks,
288 });
289 }
290 }
291
292 fn save_additional(&self, nbt: &mut NbtCompound) {
293 let state = self.state.lock();
294 if !state.item.is_empty() {
295 nbt.insert(RECORD_ITEM_TAG, state.item.to_nbt_tag_ref());
296 }
297 if let Some(playback) = &state.playback {
298 nbt.insert(
299 TICKS_SINCE_SONG_STARTED_TAG,
300 playback.ticks_since_song_started,
301 );
302 }
303 }
304
305 fn tick(&self, world: &Arc<World>) {
306 let action = {
307 let mut state = self.state.lock();
308 let Some(ticks) = state
309 .playback
310 .as_ref()
311 .map(|playback| playback.ticks_since_song_started)
312 else {
313 return;
314 };
315
316 if Self::song_value(&state.item).is_none_or(|song| Self::song_has_finished(song, ticks))
317 {
318 state.playback = None;
319 TickAction::Stop
320 } else if ticks % PLAY_EVENT_INTERVAL_TICKS == 0 {
321 TickAction::EmitPlayingEvent
322 } else {
323 TickAction::None
324 }
325 };
326
327 match action {
328 TickAction::Stop => {
329 self.emit_stop_pair(world);
330 self.on_song_changed(world);
331 return;
332 }
333 TickAction::EmitPlayingEvent => {
334 let pos = self.get_block_pos();
335 world.game_event(
336 &vanilla_game_events::JUKEBOX_PLAY,
337 pos,
338 &GameEventContext::new(None, Some(self.get_block_state())),
339 );
340 let random_color = f64::from(
341 f32::from(rand::random_range(0..NOTE_PARTICLE_COLOR_VARIANTS))
342 / NOTE_PARTICLE_COLOR_DIVISOR,
343 );
344 world.send_particles(
345 ParticleData::simple(&vanilla_particle_types::NOTE),
346 DVec3::new(
347 f64::from(pos.x()) + BLOCK_CENTER_OFFSET,
348 f64::from(pos.y()) + f64::from(NOTE_PARTICLE_Y_OFFSET),
349 f64::from(pos.z()) + BLOCK_CENTER_OFFSET,
350 ),
351 0,
352 DVec3::new(random_color, 0.0, 0.0),
353 1.0,
354 );
355 }
356 TickAction::None => {}
357 }
358
359 if let Some(playback) = &mut self.state.lock().playback {
360 playback.ticks_since_song_started = playback.ticks_since_song_started.wrapping_add(1);
361 }
362 }
363
364 fn pre_remove_side_effects(&self, _pos: BlockPos, _state: BlockStateId) {
365 self.pop_out_the_item();
366 }
367
368 fn on_set_removed(&self) {
369 if let Some(world) = self.get_level() {
370 self.emit_stop_pair(&world);
373 }
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use simdnbt::borrow::read_compound;
380 use steel_registry::data_components::components::JukeboxPlayable;
381 use steel_registry::jukebox_song::JukeboxSongValue;
382 use steel_registry::sound_event::SoundEventHolder;
383 use steel_registry::{
384 init_vanilla_registry, vanilla_blocks, vanilla_items, vanilla_jukebox_songs,
385 };
386 use steel_utils::Identifier;
387 use text_components::TextComponent;
388
389 use super::*;
390
391 const SAVED_PLAYBACK_TICKS: i64 = 37;
392
393 fn jukebox() -> JukeboxBlockEntity {
394 init_vanilla_registry();
395 JukeboxBlockEntity::new(
396 Weak::new(),
397 BlockPos::new(3, 70, -4),
398 vanilla_blocks::JUKEBOX.default_state(),
399 )
400 }
401
402 fn record_payload(item: &ItemStack, ticks: Option<i64>) -> NbtCompound {
403 let mut nbt = NbtCompound::new();
404 nbt.insert(RECORD_ITEM_TAG, item.to_nbt_tag_ref());
405 if let Some(ticks) = ticks {
406 nbt.insert(TICKS_SINCE_SONG_STARTED_TAG, ticks);
407 }
408 nbt
409 }
410
411 fn load_owned(jukebox: &JukeboxBlockEntity, nbt: &NbtCompound) {
412 let mut bytes = Vec::new();
413 nbt.write(&mut bytes);
414 let borrowed = read_compound(&mut Cursor::new(bytes.as_slice()))
415 .expect("test jukebox NBT should reborrow");
416 jukebox.load_additional(&borrowed);
417 }
418
419 #[test]
420 fn saved_song_resumes_before_but_not_at_its_vanilla_finish_tick() {
421 let record = ItemStack::new(&vanilla_items::MUSIC_DISC_CAT);
422 let song = JukeboxBlockEntity::song_value(&record)
423 .expect("vanilla music disc should carry a jukebox song");
424 let length_in_ticks = (song.length_in_seconds * TICKS_PER_SECOND as f32).ceil() as i32;
425 let finish_tick = i64::from(length_in_ticks.wrapping_add(SONG_END_PADDING_TICKS));
426
427 let before_finish = jukebox();
428 load_owned(
429 &before_finish,
430 &record_payload(&record, Some(finish_tick - 1)),
431 );
432 assert!(before_finish.is_record_playing());
433 let mut saved = NbtCompound::new();
434 before_finish.save_additional(&mut saved);
435 assert_eq!(
436 saved.long(TICKS_SINCE_SONG_STARTED_TAG),
437 Some(finish_tick - 1)
438 );
439
440 let at_finish = jukebox();
441 load_owned(&at_finish, &record_payload(&record, Some(finish_tick)));
442 assert!(!at_finish.is_record_playing());
443 assert_eq!(at_finish.analog_output_signal(), song.comparator_output);
444 }
445
446 #[test]
447 fn item_block_entity_data_merges_with_existing_record_before_loading() {
448 let record = ItemStack::new(&vanilla_items::MUSIC_DISC_PIGSTEP);
449 let jukebox = jukebox();
450 load_owned(&jukebox, &record_payload(&record, None));
451 assert!(!jukebox.is_record_playing());
452
453 let mut ticks_only = NbtCompound::new();
454 ticks_only.insert(TICKS_SINCE_SONG_STARTED_TAG, SAVED_PLAYBACK_TICKS);
455 assert!(jukebox.apply_item_block_entity_data(ticks_only));
456 assert!(jukebox.is_record_playing());
457 assert_eq!(
458 jukebox.analog_output_signal(),
459 JukeboxBlockEntity::song_value(&record)
460 .expect("vanilla music disc should carry a jukebox song")
461 .comparator_output
462 );
463
464 let mut saved = NbtCompound::new();
465 jukebox.save_additional(&mut saved);
466 assert!(saved.compound(RECORD_ITEM_TAG).is_some());
467 assert_eq!(
468 saved.long(TICKS_SINCE_SONG_STARTED_TAG),
469 Some(SAVED_PLAYBACK_TICKS)
470 );
471
472 let mut unchanged = NbtCompound::new();
473 unchanged.insert(TICKS_SINCE_SONG_STARTED_TAG, SAVED_PLAYBACK_TICKS);
474 assert!(!jukebox.apply_item_block_entity_data(unchanged));
475 }
476
477 #[test]
478 fn direct_song_uses_vanilla_unknown_registry_level_event_data() {
479 init_vanilla_registry();
480 let mut direct = ItemStack::new(&vanilla_items::STONE);
481 direct.set(
482 JUKEBOX_PLAYABLE,
483 JukeboxPlayable::direct(JukeboxSongValue {
484 sound_event: SoundEventHolder::Direct {
485 sound_id: Identifier::vanilla_static("jukebox_test_direct"),
486 fixed_range: None,
487 },
488 description: TextComponent::plain("Direct test song"),
489 length_in_seconds: 1.0,
490 comparator_output: 1,
491 }),
492 );
493
494 assert!(JukeboxBlockEntity::song_value(&direct).is_some());
495 assert_eq!(
496 JukeboxBlockEntity::song_event_data(&direct),
497 UNKNOWN_SONG_REGISTRY_ID
498 );
499
500 let reference = ItemStack::new(&vanilla_items::MUSIC_DISC_CAT);
501 assert_eq!(
502 JukeboxBlockEntity::song_event_data(&reference),
503 vanilla_jukebox_songs::CAT
504 .try_id()
505 .and_then(|id| i32::try_from(id).ok())
506 .unwrap_or(UNKNOWN_SONG_REGISTRY_ID)
507 );
508 }
509
510 #[test]
511 fn value_input_long_uses_each_numeric_tag_long_value() {
512 let mut nbt = NbtCompound::new();
513 nbt.insert("double", -0.5_f64);
514 nbt.insert("float", -0.5_f32);
515 let mut bytes = Vec::new();
516 nbt.write(&mut bytes);
517 let borrowed = read_compound(&mut Cursor::new(bytes.as_slice()))
518 .expect("numeric test NBT should reborrow");
519 let view: NbtCompoundView<'_, '_> = (&borrowed).into();
520
521 assert_eq!(view.long_value("double"), Some(-1));
522 assert_eq!(view.long_value("float"), Some(0));
523 }
524}