Skip to main content

steel_core/block_entity/entities/
jukebox.rs

1//! Vanilla jukebox block-entity storage and song playback.
2
3use 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
55/// Block entity for jukeboxes, storing the inserted record and song
56/// playback progress.
57pub struct JukeboxBlockEntity {
58    base: BlockEntityBase,
59    state: SyncMutex<JukeboxState>,
60}
61
62// SAFETY: This key is owned by Steel and uniquely identifies `JukeboxBlockEntity`.
63unsafe impl DowncastType for JukeboxBlockEntity {
64    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/jukebox");
65}
66
67impl JukeboxBlockEntity {
68    /// Creates an empty jukebox block entity.
69    #[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        // Vanilla multiplies as `float`, applies `Mth.ceil(float)`, and performs
97        // the padding addition as a wrapping Java `int` before widening.
98        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    /// Replaces the stored item and starts or stops its jukebox song.
149    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    /// Ejects the stored item with Vanilla's position, velocity, and pickup delay.
177    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        // Vanilla notifies a second time after attempting to add the entity.
208        self.on_song_changed(&world);
209    }
210
211    /// Returns whether a song is currently active.
212    #[must_use]
213    pub fn is_record_playing(&self) -> bool {
214        self.state.lock().playback.is_some()
215    }
216
217    /// Returns the stored song's extracted comparator output.
218    #[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    /// Merges an owned `BLOCK_ENTITY_DATA` payload into this jukebox.
225    ///
226    /// The placing block validates the payload's declared block-entity type
227    /// before calling this method and releases the source inventory lock first.
228    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            // Vanilla emits this pair on every removal callback, even when
371            // normal ejection already stopped and cleared the song.
372            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}