fix: jukebox now properly handles music discs (#1438)

* fix: jukebox now properly handles music discs (#568)

- Add JukeboxBlockEntity for disc storage and playback tracking
- Disc is removed from player inventory when inserted
- Disc drops when player right-clicks to retrieve it
- Disc drops when jukebox is broken
- Redstone power output (15 when playing, 0 otherwise)
- Comparator output varies per song (vanilla values 1-15)
- Playback auto-stops when song finishes

* refactor: use synced registries for jukebox song data

* replace indexmap with btreemap per clippy.toml policy

* fix import ordering for cargo fmt
This commit is contained in:
Richard Marshall
2026-02-07 11:07:56 +00:00
committed by GitHub
parent fee9fa3d76
commit 042e12d026
4 changed files with 482 additions and 36 deletions

View File

@@ -1,18 +1,40 @@
use heck::ToPascalCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
#[derive(Deserialize)]
struct JukeboxSongData {
length_in_seconds: f32,
comparator_output: u8,
}
pub(crate) fn build() -> TokenStream {
println!("cargo:rerun-if-changed=../assets/jukebox_song.json");
println!("cargo:rerun-if-changed=../assets/registry/1_21_11_synced_registries.json");
let songs: BTreeMap<String, u32> = serde_json::from_str(
&fs::read_to_string("../assets/jukebox_song.json").expect("Missing jukebox_song.json"),
)
.expect("Failed to parse jukebox_song.json");
// Helper to handle numeric keys like "11" -> "Id11"
let registries: BTreeMap<String, Value> = serde_json::from_str(
&fs::read_to_string("../assets/registry/1_21_11_synced_registries.json")
.expect("Missing synced_registries.json"),
)
.expect("Failed to parse synced_registries.json");
let song_data: BTreeMap<String, JukeboxSongData> = serde_json::from_value(
registries
.get("jukebox_song")
.expect("Missing jukebox_song in synced registries")
.clone(),
)
.expect("Failed to parse jukebox_song data");
let make_variant_ident = |name: &str| {
let pascal = name.to_pascal_case();
if pascal.chars().next().is_some_and(|c| c.is_ascii_digit()) {
@@ -54,6 +76,30 @@ pub(crate) fn build() -> TokenStream {
})
.collect::<TokenStream>();
let type_to_length = songs
.keys()
.map(|name| {
let variant_name = make_variant_ident(name);
let length = song_data
.get(name)
.map(|d| d.length_in_seconds as u32)
.unwrap_or(0);
quote! { Self::#variant_name => #length, }
})
.collect::<TokenStream>();
let type_to_comparator = songs
.keys()
.map(|name| {
let variant_name = make_variant_ident(name);
let output = song_data
.get(name)
.map(|d| d.comparator_output)
.unwrap_or(0);
quote! { Self::#variant_name => #output, }
})
.collect::<TokenStream>();
quote! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u32)]
@@ -83,6 +129,25 @@ pub(crate) fn build() -> TokenStream {
#type_to_id
}
}
#[doc = r" Returns the comparator output value (0-15) for this song."]
pub const fn comparator_output(&self) -> u8 {
match self {
#type_to_comparator
}
}
#[doc = r" Returns the song length in seconds."]
pub const fn length_in_seconds(&self) -> u32 {
match self {
#type_to_length
}
}
#[doc = r" Returns the song length in ticks (20 ticks per second)."]
pub const fn length_in_ticks(&self) -> u64 {
self.length_in_seconds() as u64 * 20
}
}
}
}

View File

@@ -0,0 +1,233 @@
use std::any::Any;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use pumpkin_nbt::compound::NbtCompound;
use pumpkin_util::math::position::BlockPos;
use tokio::sync::Mutex;
use crate::inventory::{Clearable, Inventory, InventoryFuture};
use crate::world::SimpleWorld;
use crate::{block::entities::BlockEntity, item::ItemStack};
/// Matches vanilla's JukeboxBlockEntity
pub struct JukeboxBlockEntity {
position: BlockPos,
/// The record item stored in the jukebox (RecordItem in NBT)
record_stack: Arc<Mutex<ItemStack>>,
/// Ticks since the current song started playing
ticks_since_song_started: AtomicU64,
/// Length of the current song in ticks (0 if not playing)
song_length_ticks: AtomicU64,
dirty: AtomicBool,
}
const RECORD_ITEM_NBT_KEY: &str = "RecordItem";
const TICKS_SINCE_SONG_STARTED_NBT_KEY: &str = "ticks_since_song_started";
impl BlockEntity for JukeboxBlockEntity {
fn resource_location(&self) -> &'static str {
Self::ID
}
fn get_position(&self) -> BlockPos {
self.position
}
fn from_nbt(nbt: &NbtCompound, position: BlockPos) -> Self
where
Self: Sized,
{
let record_stack = nbt
.get_compound(RECORD_ITEM_NBT_KEY)
.and_then(ItemStack::read_item_stack)
.unwrap_or_else(|| ItemStack::EMPTY.clone());
let ticks_since_song_started =
nbt.get_long(TICKS_SINCE_SONG_STARTED_NBT_KEY).unwrap_or(0) as u64;
Self {
position,
record_stack: Arc::new(Mutex::new(record_stack)),
ticks_since_song_started: AtomicU64::new(ticks_since_song_started),
song_length_ticks: AtomicU64::new(0), // Will be set when playing starts
dirty: AtomicBool::new(false),
}
}
fn write_nbt<'a>(
&'a self,
nbt: &'a mut NbtCompound,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let record = self.record_stack.lock().await;
if !record.is_empty() {
let mut record_nbt = NbtCompound::new();
record.write_item_stack(&mut record_nbt);
nbt.put(RECORD_ITEM_NBT_KEY, record_nbt);
}
let ticks = self.ticks_since_song_started.load(Ordering::Relaxed);
if ticks > 0 {
nbt.put_long(TICKS_SINCE_SONG_STARTED_NBT_KEY, ticks as i64);
}
})
}
fn tick<'a>(
&'a self,
_world: &'a Arc<dyn SimpleWorld>,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
// Increment ticks if we're playing
let song_length = self.song_length_ticks.load(Ordering::Relaxed);
if song_length > 0 {
let ticks = self
.ticks_since_song_started
.fetch_add(1, Ordering::Relaxed);
// Check if song has finished
if ticks >= song_length {
self.stop_playing();
// TODO: Update block state to has_record = false? Or just stop redstone?
// In vanilla, the disc stays but music stops and redstone turns off
}
}
})
}
fn is_dirty(&self) -> bool {
self.dirty.load(Ordering::Relaxed)
}
fn as_any(&self) -> &dyn Any {
self
}
fn get_inventory(self: Arc<Self>) -> Option<Arc<dyn Inventory>> {
Some(self)
}
}
impl JukeboxBlockEntity {
pub const ID: &'static str = "minecraft:jukebox";
#[must_use]
pub fn new(position: BlockPos) -> Self {
Self {
position,
record_stack: Arc::new(Mutex::new(ItemStack::EMPTY.clone())),
ticks_since_song_started: AtomicU64::new(0),
song_length_ticks: AtomicU64::new(0),
dirty: AtomicBool::new(false),
}
}
/// Get the current record stack
pub async fn get_record(&self) -> ItemStack {
self.record_stack.lock().await.clone()
}
/// Set the record stack - matches vanilla's setStack()
/// Note: The caller is responsible for updating block state and playing music
pub async fn set_record(&self, stack: ItemStack) {
*self.record_stack.lock().await = stack;
self.mark_dirty();
}
/// Clear the stack and return what was there - used for dropping
pub async fn clear_record(&self) -> ItemStack {
self.stop_playing();
let mut record = self.record_stack.lock().await;
let taken = record.clone();
*record = ItemStack::EMPTY.clone();
self.mark_dirty();
taken
}
/// Start playing a song with the given length in ticks
pub fn start_playing(&self, length_in_ticks: u64) {
self.ticks_since_song_started.store(0, Ordering::Relaxed);
self.song_length_ticks
.store(length_in_ticks, Ordering::Relaxed);
self.mark_dirty();
}
/// Stop playing the current song
pub fn stop_playing(&self) {
self.ticks_since_song_started.store(0, Ordering::Relaxed);
self.song_length_ticks.store(0, Ordering::Relaxed);
self.mark_dirty();
}
/// Check if a song is currently playing
pub fn is_playing(&self) -> bool {
let song_length = self.song_length_ticks.load(Ordering::Relaxed);
if song_length == 0 {
return false;
}
let ticks = self.ticks_since_song_started.load(Ordering::Relaxed);
ticks < song_length
}
fn mark_dirty(&self) {
self.dirty.store(true, Ordering::Relaxed);
}
}
/// Implements single-slot inventory for jukebox (matches vanilla's SingleStackInventory)
impl Inventory for JukeboxBlockEntity {
fn size(&self) -> usize {
1
}
fn is_empty(&self) -> InventoryFuture<'_, bool> {
Box::pin(async move { self.record_stack.lock().await.is_empty() })
}
fn get_stack(&self, _slot: usize) -> InventoryFuture<'_, Arc<Mutex<ItemStack>>> {
Box::pin(async move { self.record_stack.clone() })
}
fn remove_stack(&self, _slot: usize) -> InventoryFuture<'_, ItemStack> {
Box::pin(async move {
self.stop_playing();
let mut record = self.record_stack.lock().await;
let taken = record.clone();
*record = ItemStack::EMPTY.clone();
self.mark_dirty();
taken
})
}
fn remove_stack_specific(&self, _slot: usize, _amount: u8) -> InventoryFuture<'_, ItemStack> {
// Jukebox only holds one item, so remove the whole stack
self.remove_stack(0)
}
fn set_stack(&self, _slot: usize, stack: ItemStack) -> InventoryFuture<'_, ()> {
Box::pin(async move {
*self.record_stack.lock().await = stack;
self.mark_dirty();
})
}
fn mark_dirty(&self) {
self.dirty.store(true, Ordering::Relaxed);
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl Clearable for JukeboxBlockEntity {
fn clear(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
self.stop_playing();
*self.record_stack.lock().await = ItemStack::EMPTY.clone();
self.mark_dirty();
})
}
}

View File

@@ -18,6 +18,7 @@ use crate::block::entities::blasting_furnace::BlastingFurnaceBlockEntity;
use crate::block::entities::command_block::CommandBlockEntity;
use crate::block::entities::ender_chest::EnderChestBlockEntity;
use crate::block::entities::hopper::HopperBlockEntity;
use crate::block::entities::jukebox::JukeboxBlockEntity;
use crate::block::entities::mob_spawner::MobSpawnerBlockEntity;
use crate::block::entities::shulker_box::ShulkerBoxBlockEntity;
use crate::block::entities::smoker::SmokerBlockEntity;
@@ -39,6 +40,7 @@ pub mod ender_chest;
pub mod furnace;
pub mod furnace_like_block_entity;
pub mod hopper;
pub mod jukebox;
pub mod mob_spawner;
pub mod piston;
pub mod shulker_box;
@@ -136,6 +138,7 @@ pub fn block_entity_from_nbt(nbt: &NbtCompound) -> Option<Arc<dyn BlockEntity>>
EnderChestBlockEntity::ID => {
Arc::new(block_entity_from_generic::<EnderChestBlockEntity>(nbt))
}
JukeboxBlockEntity::ID => Arc::new(block_entity_from_generic::<JukeboxBlockEntity>(nbt)),
SignBlockEntity::ID => Arc::new(block_entity_from_generic::<SignBlockEntity>(nbt)),
BedBlockEntity::ID => Arc::new(block_entity_from_generic::<BedBlockEntity>(nbt)),
ComparatorBlockEntity::ID => {

View File

@@ -1,9 +1,15 @@
use std::sync::Arc;
use crate::block::registry::BlockActionResult;
use crate::block::{BlockBehaviour, BlockFuture, BrokenArgs, UseWithItemArgs};
use crate::block::{
BlockBehaviour, BlockFuture, BrokenArgs, EmitsRedstonePowerArgs, GetComparatorOutputArgs,
GetRedstonePowerArgs, NormalUseArgs, OnStateReplacedArgs, PlacedArgs, UseWithItemArgs,
};
use crate::entity::Entity;
use crate::entity::item::ItemEntity;
use crate::world::World;
use pumpkin_data::data_component_impl::JukeboxPlayableImpl;
use pumpkin_data::entity::EntityType;
use pumpkin_data::jukebox_song::JukeboxSong;
use pumpkin_data::world::WorldEvent;
use pumpkin_data::{
@@ -12,94 +18,233 @@ use pumpkin_data::{
};
use pumpkin_macros::pumpkin_block;
use pumpkin_util::math::position::BlockPos;
use pumpkin_util::math::vector3::Vector3;
use pumpkin_world::block::entities::jukebox::JukeboxBlockEntity;
use pumpkin_world::world::BlockFlags;
use rand::{RngExt, rng};
#[pumpkin_block("minecraft:jukebox")]
pub struct JukeboxBlock;
impl JukeboxBlock {
async fn has_record(&self, block: &Block, location: &BlockPos, world: &World) -> bool {
let state_id = world.get_block_state(location).await.id;
fn has_record_state(block: &Block, state_id: u16) -> bool {
JukeboxLikeProperties::from_state_id(state_id, block).has_record
}
async fn set_record(
&self,
async fn set_record_state(
has_record: bool,
block: &Block,
location: &BlockPos,
position: &BlockPos,
world: &Arc<World>,
) {
let new_state = JukeboxLikeProperties { has_record };
world
.set_block_state(location, new_state.to_state_id(block), BlockFlags::empty())
.set_block_state(
position,
new_state.to_state_id(block),
BlockFlags::NOTIFY_LISTENERS,
)
.await;
}
async fn stop_music(&self, block: &Block, position: &BlockPos, world: &Arc<World>) {
self.set_record(false, block, position, world).await;
/// Drops the record from the jukebox - matches vanilla's `JukeboxBlockEntity.dropRecord()`
/// Spawns item at (pos + 0.5, pos + 1.01, pos + 0.5) with horizontal random offset
async fn drop_record(position: &BlockPos, world: &Arc<World>) {
if let Some(block_entity) = world.get_block_entity(position).await
&& let Some(jukebox_entity) = block_entity.as_any().downcast_ref::<JukeboxBlockEntity>()
{
let record = jukebox_entity.clear_record().await;
if !record.is_empty() {
// Vanilla: Vec3d.add(pos, 0.5, 1.01, 0.5).addHorizontalRandom(random, 0.7F)
// addHorizontalRandom adds random in range [-0.35, 0.35] to x and z
let spawn_pos = Vector3::new(
f64::from(position.0.x) + 0.5 + rng().random_range(-0.35..0.35),
f64::from(position.0.y) + 1.01,
f64::from(position.0.z) + 0.5 + rng().random_range(-0.35..0.35),
);
let entity = Entity::new(world.clone(), spawn_pos, &EntityType::ITEM);
// Vanilla: setToDefaultPickupDelay() = 10 ticks
let item_entity = Arc::new(ItemEntity::new(entity, record).await);
world.spawn_entity(item_entity).await;
}
}
}
/// Stops the music and updates block state
async fn stop_playing(block: &Block, position: &BlockPos, world: &Arc<World>) {
Self::set_record_state(false, block, position, world).await;
world
.sync_world_event(WorldEvent::JukeboxStopsPlaying, *position, 0)
.await;
}
/// Starts playing music
async fn start_playing(position: &BlockPos, world: &Arc<World>, song_id: u32) {
world
.sync_world_event(WorldEvent::JukeboxStartsPlaying, *position, song_id as i32)
.await;
}
}
impl BlockBehaviour for JukeboxBlock {
/// Called when the jukebox is placed - creates the block entity
fn placed<'a>(&'a self, args: PlacedArgs<'a>) -> BlockFuture<'a, ()> {
Box::pin(async move {
let block_entity = JukeboxBlockEntity::new(*args.position);
args.world.add_block_entity(Arc::new(block_entity)).await;
})
}
/// Called when player right-clicks with empty hand or non-disc item
/// Vanilla: `JukeboxBlock.onUse()` - drops record if present
fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> {
Box::pin(async move {
let state_id = args.world.get_block_state(args.position).await.id;
// Vanilla: if (state.get(HAS_RECORD) && world.getBlockEntity(pos) instanceof JukeboxBlockEntity lv)
if Self::has_record_state(args.block, state_id) {
// Drop the record
Self::drop_record(args.position, args.world).await;
// Stop the music and update block state
Self::stop_playing(args.block, args.position, args.world).await;
return BlockActionResult::Success;
}
BlockActionResult::Pass
})
}
/// Called when player right-clicks with an item
/// Vanilla: `JukeboxBlock.onUseWithItem()` -> `JukeboxPlayableComponent.tryPlayStack()`
fn use_with_item<'a>(
&'a self,
args: UseWithItemArgs<'a>,
) -> BlockFuture<'a, BlockActionResult> {
Box::pin(async move {
let world = args.player.living_entity.entity.world.load_full();
let world = args.world;
let state_id = world.get_block_state(args.position).await.id;
// if the jukebox already has a record, stop playing
if self.has_record(args.block, args.position, &world).await {
self.stop_music(args.block, args.position, &world).await;
return BlockActionResult::Success;
// Vanilla: if (state.get(HAS_RECORD)) return PASS_TO_DEFAULT_BLOCK_ACTION
if Self::has_record_state(args.block, state_id) {
return BlockActionResult::PassToDefaultBlockAction;
}
let jukebox_playable = args
.item_stack
.lock()
.await
let mut item_stack = args.item_stack.lock().await;
// Vanilla: JukeboxPlayableComponent lv = stack.get(DataComponentTypes.JUKEBOX_PLAYABLE)
let jukebox_playable = item_stack
.get_data_component::<JukeboxPlayableImpl>()
.map(|i| i.song);
// Vanilla: if (lv == null) return PASS_TO_DEFAULT_BLOCK_ACTION
let Some(jukebox_playable) = jukebox_playable else {
return BlockActionResult::Pass;
return BlockActionResult::PassToDefaultBlockAction;
};
let Some(song) = jukebox_playable.split(':').nth(1) else {
return BlockActionResult::Pass;
let Some(song_name) = jukebox_playable.split(':').nth(1) else {
return BlockActionResult::PassToDefaultBlockAction;
};
let Some(jukebox_song) = JukeboxSong::from_name(song) else {
log::error!("Jukebox playable song not registered!");
return BlockActionResult::Pass;
let Some(jukebox_song) = JukeboxSong::from_name(song_name) else {
log::error!("Jukebox playable song not registered: {song_name}");
return BlockActionResult::PassToDefaultBlockAction;
};
// TODO: Update block nbt
// Vanilla: ItemStack lv3 = stack.splitUnlessCreative(1, player)
let record = item_stack.split_unless_creative(args.player.gamemode.load(), 1);
self.set_record(true, args.block, args.position, &world)
.await;
world
.sync_world_event(
WorldEvent::JukeboxStartsPlaying,
*args.position,
jukebox_song.get_id() as i32,
)
.await;
// Vanilla: lv4.setStack(lv3)
if let Some(block_entity) = world.get_block_entity(args.position).await
&& let Some(jukebox_entity) =
block_entity.as_any().downcast_ref::<JukeboxBlockEntity>()
{
jukebox_entity.set_record(record).await;
// Start tracking playback with song duration
jukebox_entity.start_playing(jukebox_song.length_in_ticks());
}
// Update block state to has_record = true
Self::set_record_state(true, args.block, args.position, world).await;
// Start playing the music (client-side audio)
Self::start_playing(args.position, world, jukebox_song.get_id()).await;
// TODO: world.emitGameEvent(GameEvent.BLOCK_CHANGE, pos, ...)
// TODO: player.incrementStat(Stats.PLAY_RECORD)
BlockActionResult::Success
})
}
/// Called when the jukebox is broken
fn broken<'a>(&'a self, args: BrokenArgs<'a>) -> BlockFuture<'a, ()> {
Box::pin(async move {
// For now just stop the music at this position
// Drop the record if there is one
Self::drop_record(args.position, args.world).await;
// Stop the music
args.world
.sync_world_event(WorldEvent::JukeboxStopsPlaying, *args.position, 0)
.await;
})
}
/// Vanilla: `JukeboxBlock.onStateReplaced()` -> `ItemScatterer.onStateReplaced()`
fn on_state_replaced<'a>(&'a self, _args: OnStateReplacedArgs<'a>) -> BlockFuture<'a, ()> {
Box::pin(async move {
// Vanilla calls ItemScatterer.onStateReplaced which updates comparators
// TODO: world.updateComparators(pos, block) when implemented
})
}
/// Vanilla: `JukeboxBlock.emitsRedstonePower()` returns true
fn emits_redstone_power<'a>(
&'a self,
_args: EmitsRedstonePowerArgs<'a>,
) -> BlockFuture<'a, bool> {
Box::pin(async move { true })
}
/// Vanilla: Returns 15 if playing, 0 otherwise
fn get_weak_redstone_power<'a>(
&'a self,
args: GetRedstonePowerArgs<'a>,
) -> BlockFuture<'a, u8> {
Box::pin(async move {
// Vanilla: return world.getBlockEntity(pos) instanceof JukeboxBlockEntity lv && lv.getManager().isPlaying() ? 15 : 0
if let Some(block_entity) = args.world.get_block_entity(args.position).await
&& let Some(jukebox_entity) =
block_entity.as_any().downcast_ref::<JukeboxBlockEntity>()
&& jukebox_entity.is_playing()
{
15
} else {
0
}
})
}
/// Vanilla: Returns the song's comparator output (0-15)
fn get_comparator_output<'a>(
&'a self,
args: GetComparatorOutputArgs<'a>,
) -> BlockFuture<'a, Option<u8>> {
Box::pin(async move {
// Vanilla: return world.getBlockEntity(pos) instanceof JukeboxBlockEntity lv ? lv.getComparatorOutput() : 0
if let Some(block_entity) = args.world.get_block_entity(args.position).await
&& let Some(jukebox_entity) =
block_entity.as_any().downcast_ref::<JukeboxBlockEntity>()
{
let record = jukebox_entity.get_record().await;
// Get the song from the record's jukebox_playable component
if let Some(playable) = record.get_data_component::<JukeboxPlayableImpl>()
&& let Some(song_name) = playable.song.split(':').nth(1)
&& let Some(song) = JukeboxSong::from_name(song_name)
{
return Some(song.comparator_output());
}
}
Some(0)
})
}
}