diff --git a/pumpkin-data/build/jukebox_song.rs b/pumpkin-data/build/jukebox_song.rs index a8e3fe7fb..f43b5be99 100644 --- a/pumpkin-data/build/jukebox_song.rs +++ b/pumpkin-data/build/jukebox_song.rs @@ -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 = 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 = 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 = 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::(); + 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::(); + + 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::(); + 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 + } } } } diff --git a/pumpkin-world/src/block/entities/jukebox.rs b/pumpkin-world/src/block/entities/jukebox.rs new file mode 100644 index 000000000..7b2a5024e --- /dev/null +++ b/pumpkin-world/src/block/entities/jukebox.rs @@ -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>, + /// 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 + 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, + ) -> Pin + 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) -> Option> { + 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>> { + 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 + Send + '_>> { + Box::pin(async move { + self.stop_playing(); + *self.record_stack.lock().await = ItemStack::EMPTY.clone(); + self.mark_dirty(); + }) + } +} diff --git a/pumpkin-world/src/block/entities/mod.rs b/pumpkin-world/src/block/entities/mod.rs index ba7e618ce..12cdb0f40 100644 --- a/pumpkin-world/src/block/entities/mod.rs +++ b/pumpkin-world/src/block/entities/mod.rs @@ -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> EnderChestBlockEntity::ID => { Arc::new(block_entity_from_generic::(nbt)) } + JukeboxBlockEntity::ID => Arc::new(block_entity_from_generic::(nbt)), SignBlockEntity::ID => Arc::new(block_entity_from_generic::(nbt)), BedBlockEntity::ID => Arc::new(block_entity_from_generic::(nbt)), ComparatorBlockEntity::ID => { diff --git a/pumpkin/src/block/blocks/jukebox.rs b/pumpkin/src/block/blocks/jukebox.rs index ed6502bdf..ebf31d339 100644 --- a/pumpkin/src/block/blocks/jukebox.rs +++ b/pumpkin/src/block/blocks/jukebox.rs @@ -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, ) { 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) { - 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) { + if let Some(block_entity) = world.get_block_entity(position).await + && let Some(jukebox_entity) = block_entity.as_any().downcast_ref::() + { + 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) { + 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, 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::() .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::() + { + 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::() + && 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> { + 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::() + { + 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::() + && let Some(song_name) = playable.song.split(':').nth(1) + && let Some(song) = JukeboxSong::from_name(song_name) + { + return Some(song.comparator_output()); + } + } + Some(0) + }) + } }