From 0c1cb25c058af0f5a059cabbfe619a420c241087 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Mon, 22 Jun 2026 17:57:36 +0200 Subject: [PATCH] feat: set camera for spectators --- pumpkin-protocol/src/codec/data_component.rs | 3 +- pumpkin-protocol/src/java/client/play/mod.rs | 2 + .../src/java/client/play/set_camera.rs | 23 +++++ pumpkin-protocol/src/java/server/play/mod.rs | 8 ++ .../src/java/server/play/set_test_block.rs | 19 ++++ .../java/server/play/teleport_to_entity.rs | 10 +++ .../server/play/test_instance_block_action.rs | 49 +++++++++++ pumpkin/src/entity/player.rs | 42 ++++++++- pumpkin/src/item/items/bundle.rs | 46 +++++----- pumpkin/src/net/java/mod.rs | 20 ++++- pumpkin/src/net/java/play.rs | 87 ++++++++++++++++++- 11 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 pumpkin-protocol/src/java/client/play/set_camera.rs create mode 100644 pumpkin-protocol/src/java/server/play/set_test_block.rs create mode 100644 pumpkin-protocol/src/java/server/play/teleport_to_entity.rs create mode 100644 pumpkin-protocol/src/java/server/play/test_instance_block_action.rs diff --git a/pumpkin-protocol/src/codec/data_component.rs b/pumpkin-protocol/src/codec/data_component.rs index 21cf2c50b..2a016b481 100644 --- a/pumpkin-protocol/src/codec/data_component.rs +++ b/pumpkin-protocol/src/codec/data_component.rs @@ -916,6 +916,8 @@ impl DataComponentCodec for UseCooldownImpl { fn deserialize_item_stack_template<'a, A: SeqAccess<'a>>( seq: &mut A, ) -> Result { + const MAX_COMPONENTS: i32 = 256; + let item_id = seq .next_element::()? .ok_or_else(|| de::Error::custom("Missing item_id in ItemStackTemplate"))? @@ -933,7 +935,6 @@ fn deserialize_item_stack_template<'a, A: SeqAccess<'a>>( return Err(de::Error::custom("Negative component count")); } - const MAX_COMPONENTS: i32 = 256; let total_components = num_to_add .checked_add(num_to_remove) .ok_or_else(|| de::Error::custom("Component count overflow"))?; diff --git a/pumpkin-protocol/src/java/client/play/mod.rs b/pumpkin-protocol/src/java/client/play/mod.rs index c9b7555f2..2d97862f3 100644 --- a/pumpkin-protocol/src/java/client/play/mod.rs +++ b/pumpkin-protocol/src/java/client/play/mod.rs @@ -68,6 +68,7 @@ mod set_border_lerp_size; mod set_border_size; mod set_border_warning_delay; mod set_border_warning_distance; +mod set_camera; mod set_container_content; mod set_container_property; mod set_container_slot; @@ -173,6 +174,7 @@ pub use set_border_lerp_size::*; pub use set_border_size::*; pub use set_border_warning_delay::*; pub use set_border_warning_distance::*; +pub use set_camera::*; pub use set_container_content::*; pub use set_container_property::*; pub use set_container_slot::*; diff --git a/pumpkin-protocol/src/java/client/play/set_camera.rs b/pumpkin-protocol/src/java/client/play/set_camera.rs new file mode 100644 index 000000000..3c37518f8 --- /dev/null +++ b/pumpkin-protocol/src/java/client/play/set_camera.rs @@ -0,0 +1,23 @@ +use pumpkin_data::packet::clientbound::PLAY_SET_CAMERA; +use pumpkin_util::version::JavaMinecraftVersion; +use serde::Serialize; + +use crate::{VarInt, packet::MultiVersionJavaPacket}; + +#[derive(Serialize)] +pub struct CSetCamera { + pub camera_id: VarInt, +} + +impl CSetCamera { + #[must_use] + pub const fn new(camera_id: VarInt) -> Self { + Self { camera_id } + } +} + +impl MultiVersionJavaPacket for CSetCamera { + fn to_id(version: JavaMinecraftVersion) -> i32 { + PLAY_SET_CAMERA.to_id(version) + } +} diff --git a/pumpkin-protocol/src/java/server/play/mod.rs b/pumpkin-protocol/src/java/server/play/mod.rs index d788df59d..e369bdd37 100644 --- a/pumpkin-protocol/src/java/server/play/mod.rs +++ b/pumpkin-protocol/src/java/server/play/mod.rs @@ -43,6 +43,7 @@ mod set_creative_slot; mod set_held_item; mod set_jigsaw_block; mod swing_arm; +mod teleport_to_entity; mod update_sign; mod use_item; mod use_item_on; @@ -92,6 +93,13 @@ pub use set_creative_slot::*; pub use set_held_item::*; pub use set_jigsaw_block::*; pub use swing_arm::*; +pub use teleport_to_entity::*; pub use update_sign::*; pub use use_item::*; pub use use_item_on::*; + +mod test_instance_block_action; +pub use test_instance_block_action::*; + +mod set_test_block; +pub use set_test_block::*; diff --git a/pumpkin-protocol/src/java/server/play/set_test_block.rs b/pumpkin-protocol/src/java/server/play/set_test_block.rs new file mode 100644 index 000000000..b46c01854 --- /dev/null +++ b/pumpkin-protocol/src/java/server/play/set_test_block.rs @@ -0,0 +1,19 @@ +use pumpkin_data::packet::serverbound::PLAY_SET_TEST_BLOCK; +use pumpkin_macros::java_packet; +use pumpkin_util::math::position::BlockPos; + +#[derive(serde::Deserialize)] +#[java_packet(PLAY_SET_TEST_BLOCK)] +pub struct SSetTestBlock { + pub position: BlockPos, + pub mode: TestBlockMode, + pub message: String, +} + +#[derive(serde::Deserialize, Clone, Copy, Debug)] +pub enum TestBlockMode { + Start, + Log, + Fail, + Accept, +} diff --git a/pumpkin-protocol/src/java/server/play/teleport_to_entity.rs b/pumpkin-protocol/src/java/server/play/teleport_to_entity.rs new file mode 100644 index 000000000..1e11f3c80 --- /dev/null +++ b/pumpkin-protocol/src/java/server/play/teleport_to_entity.rs @@ -0,0 +1,10 @@ +use pumpkin_data::packet::serverbound::PLAY_TELEPORT_TO_ENTITY; +use pumpkin_macros::java_packet; +use serde::Deserialize; + +#[derive(Deserialize)] +#[java_packet(PLAY_TELEPORT_TO_ENTITY)] +pub struct STeleportToEntity { + #[serde(with = "uuid::serde::compact")] + pub target: uuid::Uuid, +} diff --git a/pumpkin-protocol/src/java/server/play/test_instance_block_action.rs b/pumpkin-protocol/src/java/server/play/test_instance_block_action.rs new file mode 100644 index 000000000..bc279a7c2 --- /dev/null +++ b/pumpkin-protocol/src/java/server/play/test_instance_block_action.rs @@ -0,0 +1,49 @@ +use pumpkin_data::packet::serverbound::PLAY_TEST_INSTANCE_BLOCK_ACTION; +use pumpkin_macros::java_packet; +use pumpkin_util::math::position::BlockPos; + +use crate::VarInt; + +#[derive(serde::Deserialize)] +#[java_packet(PLAY_TEST_INSTANCE_BLOCK_ACTION)] +pub struct STestInstanceBlockAction { + pub pos: BlockPos, + pub action: TestInstanceBlockAction, + pub data: TestInstanceBlockData, +} + +#[derive(serde::Deserialize, Clone, Copy, Debug)] +pub enum TestInstanceBlockAction { + Init, + Query, + Set, + Reset, + Save, + Export, + Run, +} + +#[derive(serde::Deserialize)] +pub struct VarIntVector3 { + pub x: VarInt, + pub y: VarInt, + pub z: VarInt, +} + +#[derive(serde::Deserialize)] +pub struct TestInstanceBlockData { + pub test: Option, + pub size: VarIntVector3, + pub rotation: pumpkin_data::block_rotation::Rotation, + pub ignore_entities: bool, + pub status: TestInstanceBlockStatus, + pub error_message: Option, +} + +#[derive(serde::Deserialize, Clone, Copy, Debug)] +pub enum TestInstanceBlockStatus { + Cleared, + Running, + Success, + Failed, +} diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index 5eee09ac3..19634cad3 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -67,9 +67,9 @@ use pumpkin_protocol::java::client::play::{ CCloseContainer, CCombatDeath, CCustomPayload, CDisguisedChatMessage, CEntityAnimation, CEntityPositionSync, CGameEvent, CItemCooldown, CMapItemData, COpenScreen, CParticle, CPlayerAbilities, CPlayerInfoUpdate, CPlayerPosition, CPlayerSpawnPosition, CRespawn, - CSetContainerContent, CSetContainerProperty, CSetContainerSlot, CSetCursorItem, CSetEquipment, - CSetExperience, CSetHealth, CSetPlayerInventory, CSetSelectedSlot, CSoundEffect, CStopSound, - CSubtitle, CSystemChatMessage, CTabList, CTitleAnimation, CTitleText, CUnloadChunk, + CSetCamera, CSetContainerContent, CSetContainerProperty, CSetContainerSlot, CSetCursorItem, + CSetEquipment, CSetExperience, CSetHealth, CSetPlayerInventory, CSetSelectedSlot, CSoundEffect, + CStopSound, CSubtitle, CSystemChatMessage, CTabList, CTitleAnimation, CTitleText, CUnloadChunk, CUpdateMobEffect, CUpdateTime, GameEvent, MapIcon, MapPatch, Metadata, PlayerAction, PlayerInfoFlags, PlayerSpawnData, PreviousMessage, Statistic, }; @@ -422,6 +422,8 @@ pub struct Player { pub gamemode: AtomicCell, /// The player's previous gamemode pub previous_gamemode: AtomicCell>, + /// The entity ID of the entity that the player is currently spectating/camera targeting. + pub camera_target_id: AtomicCell>, /// The player's spawnpoint pub respawn_point: Mutex>, /// The player's sleep status @@ -656,6 +658,7 @@ impl Player { stats: Mutex::new(statistics::Statistics::default()), gamemode: AtomicCell::new(gamemode), previous_gamemode: AtomicCell::new(None), + camera_target_id: AtomicCell::new(None), // TODO: Send the CPlayerSpawnPosition packet when the client connects with proper values respawn_point: Mutex::new(None), sleeping_since: AtomicCell::new(None), @@ -1859,6 +1862,32 @@ impl Player { // TODO Abstract the chunk sending #[expect(clippy::too_many_lines)] pub async fn tick(self: &Arc, server: &Server) { + if let Some(camera_id) = self.camera_target_id.load() { + if camera_id == self.entity_id() { + self.camera_target_id.store(None); + } else { + let world = self.world(); + let target = world + .get_player_by_id(camera_id) + .map(|p| Arc::clone(&p) as Arc) + .or_else(|| world.get_entity_by_id(camera_id)); + if let Some(target) = target { + let target_pos = target.get_entity().pos.load(); + let player_pos = self.living_entity.entity.pos.load(); + if player_pos != target_pos { + self.living_entity.entity.set_pos(target_pos); + crate::world::chunker::update_position(self).await; + } + } else { + // Target no longer exists, reset camera back to player + self.camera_target_id.store(None); + self.client + .send_packet_now(&CSetCamera::new(self.entity_id().into())) + .await; + } + } + } + self.current_screen_handler .lock() .await @@ -2855,6 +2884,13 @@ impl Player { } } + if gamemode != GameMode::Spectator && self.camera_target_id.load().is_some() { + self.camera_target_id.store(None); + self.client.send_packet_now(&CSetCamera::new( + self.entity_id().into() + )).await; + } + self.living_entity.entity.invulnerable.store( matches!(gamemode, GameMode::Creative | GameMode::Spectator), Ordering::Relaxed, diff --git a/pumpkin/src/item/items/bundle.rs b/pumpkin/src/item/items/bundle.rs index 34de1bbc5..b4508293e 100644 --- a/pumpkin/src/item/items/bundle.rs +++ b/pumpkin/src/item/items/bundle.rs @@ -31,20 +31,19 @@ impl ItemBehaviour for BundleItem { matched = true; if let Some(bundle_contents) = held_item.get_data_component_mut::() + && let Some(extracted_stack) = bundle_contents.try_extract() { - if let Some(extracted_stack) = bundle_contents.try_extract() { - let position = player.position(); - player.world().play_sound( - Sound::ItemBundleRemoveOne, - pumpkin_data::sound::SoundCategory::Players, - &position, - ); - let updated_bundle = held_item.clone(); - drop(held_item); + let position = player.position(); + player.world().play_sound( + Sound::ItemBundleRemoveOne, + pumpkin_data::sound::SoundCategory::Players, + &position, + ); + let updated_bundle = held_item.clone(); + drop(held_item); - player.drop_item(extracted_stack).await; - player.sync_hand_slot(used_slot_index, updated_bundle).await; - } + player.drop_item(extracted_stack).await; + player.sync_hand_slot(used_slot_index, updated_bundle).await; } } @@ -55,20 +54,19 @@ impl ItemBehaviour for BundleItem { used_slot_index = 40; // OFF_HAND_SLOT if let Some(bundle_contents) = off_hand_item.get_data_component_mut::() + && let Some(extracted_stack) = bundle_contents.try_extract() { - if let Some(extracted_stack) = bundle_contents.try_extract() { - let position = player.position(); - player.world().play_sound( - Sound::ItemBundleRemoveOne, - pumpkin_data::sound::SoundCategory::Players, - &position, - ); - let updated_bundle = off_hand_item.clone(); - drop(off_hand_item); + let position = player.position(); + player.world().play_sound( + Sound::ItemBundleRemoveOne, + pumpkin_data::sound::SoundCategory::Players, + &position, + ); + let updated_bundle = off_hand_item.clone(); + drop(off_hand_item); - player.drop_item(extracted_stack).await; - player.sync_hand_slot(used_slot_index, updated_bundle).await; - } + player.drop_item(extracted_stack).await; + player.sync_hand_slot(used_slot_index, updated_bundle).await; } } } diff --git a/pumpkin/src/net/java/mod.rs b/pumpkin/src/net/java/mod.rs index 2dcf69159..bf6ef5bfb 100644 --- a/pumpkin/src/net/java/mod.rs +++ b/pumpkin/src/net/java/mod.rs @@ -21,7 +21,8 @@ use pumpkin_protocol::java::server::play::{ SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings, SRecipeBookSeenRecipe, SRenameItem, SSelectTrade, SSetCommandBlock, SSetCreativeSlot, - SSetHeldItem, SSetJigsawBlock, SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn, + SSetHeldItem, SSetJigsawBlock, SSetPlayerGround, SSetTestBlock, SSwingArm, STeleportToEntity, + STestInstanceBlockAction, SUpdateSign, SUseItem, SUseItemOn, }; use pumpkin_protocol::packet::MultiVersionJavaPacket; use pumpkin_protocol::{ @@ -923,6 +924,14 @@ impl JavaClient { self.handle_attack(player, SAttack::read(payload, &version)?, server) .await; } + id if id == STeleportToEntity::to_id(version) => { + self.handle_teleport_to_entity( + player, + STeleportToEntity::read(payload, &version)?, + server, + ) + .await; + } id if id == pumpkin_protocol::java::server::play::SKeepAlive::to_id(version) => { self.handle_keep_alive( player, @@ -933,6 +942,15 @@ impl JavaClient { id if id == SClientTickEnd::to_id(version) => { // TODO } + id if id == STestInstanceBlockAction::to_id(version) => { + self.handle_test_instance_block_action( + player, + &STestInstanceBlockAction::read(payload, &version)?, + ); + } + id if id == SSetTestBlock::to_id(version) => { + self.handle_set_test_block(player, &SSetTestBlock::read(payload, &version)?); + } id if id == SPlayerPosition::to_id(version) => { self.handle_position(player, server, SPlayerPosition::read(payload, &version)?) .await; diff --git a/pumpkin/src/net/java/play.rs b/pumpkin/src/net/java/play.rs index e393a5dff..3a6eacc41 100644 --- a/pumpkin/src/net/java/play.rs +++ b/pumpkin/src/net/java/play.rs @@ -61,8 +61,9 @@ use pumpkin_protocol::codec::var_int::VarInt; use pumpkin_protocol::codec::var_ulong::VarULong; use pumpkin_protocol::java::client::play::{ CBlockUpdate, CCommandSuggestions, CEntityPositionSync, CHeadRot, COpenSignEditor, - CPingResponse, CPlayerInfoUpdate, CPlayerPosition, CSetSelectedSlot, CSystemChatMessage, - CUpdateEntityPos, CUpdateEntityPosRot, CUpdateEntityRot, InitChat, PlayerAction, + CPingResponse, CPlayerInfoUpdate, CPlayerPosition, CSetCamera, CSetSelectedSlot, + CSystemChatMessage, CUpdateEntityPos, CUpdateEntityPosRot, CUpdateEntityRot, InitChat, + PlayerAction, }; use pumpkin_protocol::java::server::play::{ Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SBundleItemSelected, @@ -73,7 +74,8 @@ use pumpkin_protocol::java::server::play::{ SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings, SRecipeBookSeenRecipe, SSelectTrade, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetJigsawBlock, - SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn, Status, + SSetPlayerGround, SSetTestBlock, SSwingArm, STeleportToEntity, STestInstanceBlockAction, + SUpdateSign, SUseItem, SUseItemOn, Status, }; use pumpkin_util::math::boundingbox::BoundingBox; use pumpkin_util::math::vector3::Vector3; @@ -987,6 +989,17 @@ impl JavaClient { player.last_input.store(input.input, Ordering::Relaxed); let sneak = input.input & SPlayerInput::SNEAK != 0; + if sneak + && player.gamemode.load() == GameMode::Spectator + && player.camera_target_id.load().is_some() + { + player.camera_target_id.store(None); + player + .client + .send_packet_now(&CSetCamera::new(player.entity_id().into())) + .await; + } + if player.get_entity().is_sneaking() != sneak { send_cancellable! {{ server; @@ -1699,6 +1712,7 @@ impl JavaClient { player.attack(target).await; } + #[expect(clippy::too_many_lines)] pub async fn handle_interact( &self, player: &Arc, @@ -1730,6 +1744,14 @@ impl JavaClient { .or_else(|| world.get_entity_by_id(entity_id.0)); if let Some(target) = target { + if player.gamemode.load() == GameMode::Spectator { + player.camera_target_id.store(Some(entity_id.0)); + player + .client + .send_packet_now(&CSetCamera::new(entity_id)) + .await; + return; + } send_cancellable! {{ server; PlayerInteractEntityEvent::new( @@ -2942,4 +2964,63 @@ impl JavaClient { packet.slot_id.0, selected_item_index ); } + + pub async fn handle_teleport_to_entity( + &self, + player: &Arc, + packet: STeleportToEntity, + server: &Server, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + + if player.gamemode.load() != GameMode::Spectator { + return; + } + + if let Some(target_player) = server.get_player_by_uuid(packet.target) { + let target_pos = target_player.living_entity.entity.pos.load(); + let target_yaw = target_player.living_entity.entity.yaw.load(); + let target_pitch = target_player.living_entity.entity.pitch.load(); + + let target_id = target_player.living_entity.entity.entity_id; + player.camera_target_id.store(Some(target_id)); + player + .client + .send_packet_now(&CSetCamera::new(target_id.into())) + .await; + + player + .request_teleport(target_pos, target_yaw, target_pitch) + .await; + } + } + + pub fn handle_test_instance_block_action( + &self, + player: &Arc, + packet: &STestInstanceBlockAction, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + debug!( + "Test instance block action at {:?}: action={:?}", + packet.pos, packet.action + ); + } + + pub fn handle_set_test_block(&self, player: &Arc, packet: &SSetTestBlock) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + debug!( + "Set test block at {:?}: mode={:?}, message={}", + packet.position, packet.mode, packet.message + ); + } }