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