feat: add map creation

This commit is contained in:
Alexander Medvedev
2026-05-08 11:24:35 +02:00
parent 4e3268b140
commit 4c26afc708
17 changed files with 14160 additions and 12564 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -639,6 +639,7 @@ pub struct Block {
pub hardness: f32,
/// Blast resistance against explosions.
pub blast_resistance: f32,
pub map_color: u8,
/// Numeric ID of the corresponding item, if any.
pub item_id: u16,
/// Flammability data, present only if the block can catch fire.
@@ -668,6 +669,8 @@ impl ToTokens for Block {
//let translation_key = LitStr::new(&self.translation_key, Span::call_site());
let hardness = &self.hardness;
let blast_resistance = &self.blast_resistance;
let map_color = &self.map_color;
let item_id = LitInt::new(&self.item_id.to_string(), Span::call_site());
let slipperiness = &self.slipperiness;
let velocity_multiplier = &self.velocity_multiplier;
@@ -707,6 +710,7 @@ impl ToTokens for Block {
name: #name,
hardness: #hardness,
blast_resistance: #blast_resistance,
map_color: #map_color,
slipperiness: #slipperiness,
velocity_multiplier: #velocity_multiplier,
jump_velocity_multiplier: #jump_velocity_multiplier,

View File

@@ -24,6 +24,7 @@ pub struct Block {
pub hardness: f32,
/// The block's resistance to explosions.
pub blast_resistance: f32,
pub map_color: u8,
/// The friction coefficient. Default is 0.6; Ice is 0.98.
pub slipperiness: f32,
/// How much this block affects the speed of an entity walking on it (e.g., Soul Sand).

View File

@@ -5,7 +5,7 @@ use crate::data_component::DataComponent;
use crate::data_component::DataComponent::{
AttributeModifiers, BlocksAttacks, Consumable, CustomData, CustomName, Damage, DamageResistant,
DeathProtection, Enchantable, Enchantments, Equippable, FireworkExplosion, Fireworks, Food,
ItemModel, ItemName, JukeboxPlayable, MaxDamage, MaxStackSize, PotionContents,
ItemModel, ItemName, JukeboxPlayable, MapId, MaxDamage, MaxStackSize, PotionContents,
StoredEnchantments, Tool, Unbreakable, UseCooldown, Weapon,
};
use crate::effect::{self, StatusEffect};
@@ -69,6 +69,7 @@ pub fn read_data(id: DataComponent, data: &NbtTag) -> Option<Box<dyn DataCompone
Equippable => Some(EquippableImpl::read_data(data)?.to_dyn()),
StoredEnchantments => Some(StoredEnchantmentsImpl::read_data(data)?.to_dyn()),
UseCooldown => Some(UseCooldownImpl::read_data(data)?.to_dyn()),
MapId => Some(MapIdImpl::read_data(data)?.to_dyn()),
_ => None,
}
}
@@ -83,6 +84,7 @@ pub fn read_data_pnbt(
Enchantments => Some(EnchantmentsImpl::read_data_pnbt(nbt)?.to_dyn()),
Damage => Some(DamageImpl::read_data_pnbt(nbt)?.to_dyn()),
Unbreakable => Some(UnbreakableImpl::read_data_pnbt(nbt)?.to_dyn()),
MapId => Some(MapIdImpl::read_data_pnbt(nbt)?.to_dyn()),
_ => None,
}
}
@@ -414,6 +416,7 @@ fn hash() {
-1580618251i32
);
assert_eq!(MaxStackSizeImpl { size: 99 }.get_hash(), -1632321551i32);
assert_eq!(MapIdImpl { id: 10 }.get_hash(), -919192125i32);
}
impl DataComponentImpl for EnchantmentsImpl {
@@ -1557,7 +1560,35 @@ pub struct DyedColorImpl;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct MapColorImpl;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct MapIdImpl;
pub struct MapIdImpl {
pub id: i32,
}
impl MapIdImpl {
fn read_data(data: &NbtTag) -> Option<Self> {
data.extract_int().map(|id| Self { id })
}
fn read_data_pnbt(nbt: &mut PNbtCompound) -> Option<Self> {
nbt.get_i32().ok().map(|id| Self { id })
}
}
impl DataComponentImpl for MapIdImpl {
fn write_data(&self) -> NbtTag {
NbtTag::Int(self.id)
}
fn write_data_pnbt(&self, nbt: &mut PNbtCompound) {
nbt.put_i32(self.id);
}
fn get_hash(&self) -> i32 {
get_i32_hash(self.id) as i32
}
default_impl!(MapId);
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct MapDecorationsImpl;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,9 @@ use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::{
ConsumableImpl, ConsumeAnimation, ConsumeEffect, CustomNameImpl, DamageImpl, DataComponentImpl,
EnchantmentsImpl, EquipmentSlot, EquippableImpl, FireworkExplosionImpl, FireworkExplosionShape,
FireworksImpl, IDSet, IDSetContent, IdOr, ItemModelImpl, MaxStackSizeImpl, PotionContentsImpl,
SoundEvent, StatusEffectInstance, StoredEnchantmentsImpl, UnbreakableImpl, UseCooldownImpl,
get,
FireworksImpl, IDSet, IDSetContent, IdOr, ItemModelImpl, MapIdImpl, MaxStackSizeImpl,
PotionContentsImpl, SoundEvent, StatusEffectInstance, StoredEnchantmentsImpl, UnbreakableImpl,
UseCooldownImpl, get,
};
use pumpkin_data::effect::StatusEffect;
use pumpkin_data::entity::EntityType;
@@ -844,6 +844,7 @@ pub fn deserialize<'a, A: SeqAccess<'a>>(
DataComponent::Equippable => Ok(EquippableImpl::deserialize(seq)?.to_dyn()),
DataComponent::StoredEnchantments => Ok(StoredEnchantmentsImpl::deserialize(seq)?.to_dyn()),
DataComponent::UseCooldown => Ok(UseCooldownImpl::deserialize(seq)?.to_dyn()),
DataComponent::MapId => Ok(MapIdImpl::deserialize(seq)?.to_dyn()),
_ => Err(serde::de::Error::custom(format!("{id:?} (TODO)"))),
}
}
@@ -866,10 +867,25 @@ pub fn serialize<T: SerializeStruct>(
DataComponent::Equippable => get::<EquippableImpl>(value).serialize(seq),
DataComponent::StoredEnchantments => get::<StoredEnchantmentsImpl>(value).serialize(seq),
DataComponent::UseCooldown => get::<UseCooldownImpl>(value).serialize(seq),
DataComponent::MapId => get::<MapIdImpl>(value).serialize(seq),
_ => todo!("{} not yet implemented", id.to_name()),
}
}
impl DataComponentCodec<Self> for MapIdImpl {
fn serialize<T: SerializeStruct>(&self, seq: &mut T) -> Result<(), T::Error> {
seq.serialize_field::<VarInt>("", &VarInt::from(self.id))
}
fn deserialize<'a, A: SeqAccess<'a>>(seq: &mut A) -> Result<Self, A::Error> {
let id = seq
.next_element::<VarInt>()?
.ok_or(de::Error::custom("No MapId VarInt!"))?
.0;
Ok(Self { id })
}
}
impl DataComponentCodec<Self> for UseCooldownImpl {
fn serialize<T: SerializeStruct>(&self, seq: &mut T) -> Result<(), T::Error> {
seq.serialize_field::<f32>("", &self.seconds)?;

View File

@@ -0,0 +1,75 @@
use crate::{ClientPacket, VarInt, WritingError, ser::NetworkWriteExt};
use pumpkin_data::packet::clientbound::PLAY_MAP_ITEM_DATA;
use pumpkin_macros::java_packet;
use std::io::Write;
#[java_packet(PLAY_MAP_ITEM_DATA)]
pub struct CMapItemData<'a> {
pub map_id: VarInt,
pub scale: i8,
pub locked: bool,
pub icons: Option<&'a [MapIcon]>,
pub data: Option<MapPatch<'a>>,
}
pub struct MapIcon {
pub icon_type: VarInt,
pub x: i8,
pub z: i8,
pub direction: i8,
pub display_name: Option<String>,
}
pub struct MapPatch<'a> {
pub columns: u8,
pub rows: u8,
pub x: i8,
pub z: i8,
pub data: &'a [u8],
}
impl ClientPacket for CMapItemData<'_> {
fn write_packet_data(
&self,
mut write: impl Write,
_version: &pumpkin_util::version::MinecraftVersion,
) -> Result<(), WritingError> {
write.write_var_int(&self.map_id)?;
write.write_i8(self.scale)?;
write.write_bool(self.locked)?;
if let Some(icons) = self.icons {
write.write_bool(true)?;
write.write_var_int(&VarInt(icons.len() as i32))?;
for icon in icons {
write.write_var_int(&icon.icon_type)?;
write.write_i8(icon.x)?;
write.write_i8(icon.z)?;
write.write_i8(icon.direction)?;
if let Some(name) = &icon.display_name {
write.write_bool(true)?;
write.write_string(&format!("{{\"text\":\"{name}\"}}"))?;
} else {
write.write_bool(false)?;
}
}
} else {
write.write_bool(false)?;
}
if let Some(patch) = &self.data {
write.write_u8(patch.columns)?;
if patch.columns > 0 {
write.write_u8(patch.rows)?;
write.write_i8(patch.x)?;
write.write_i8(patch.z)?;
write.write_var_int(&VarInt(patch.data.len() as i32))?;
write.write_all(patch.data).map_err(WritingError::IoError)?;
}
} else {
write.write_u8(0)?;
}
Ok(())
}
}

View File

@@ -38,6 +38,7 @@ mod keep_alive;
mod level_event;
mod light_update;
mod login;
mod map_item_data;
mod merchant_offers;
mod multi_block_update;
mod open_screen;
@@ -139,6 +140,7 @@ pub use keep_alive::*;
pub use level_event::*;
pub use light_update::*;
pub use login::*;
pub use map_item_data::*;
pub use merchant_offers::*;
pub use multi_block_update::*;
pub use open_screen::*;

View File

@@ -237,6 +237,7 @@ mod test {
snapshot: false,
series: "main".to_string(),
},
map_id: 0,
},
});

View File

@@ -80,6 +80,8 @@ pub struct LevelData {
pub world_version: WorldVersion,
#[serde(rename = "version", default = "default_level_version")]
pub level_version: i32,
#[serde(rename = "map_id", default)]
pub map_id: i32,
}
const DEFAULT_BORDER_DAMAGE_PER_BLOCK: f64 = 0.2;
@@ -325,6 +327,7 @@ impl LevelData {
spawn_pitch: 0.0,
world_version: WorldVersion::default(),
level_version: MAXIMUM_SUPPORTED_LEVEL_VERSION,
map_id: 0,
}
}

View File

@@ -61,12 +61,12 @@ use pumpkin_protocol::java::client::play::{
Animation, CAcknowledgeBlockChange, CActionBar, CChangeDifficulty, CChunkBatchEnd,
CChunkBatchStart, CChunkData, CCloseContainer, CCombatDeath, CCustomPayload,
CDisguisedChatMessage, CEntityAnimation, CEntityPositionSync, CGameEvent, CItemCooldown,
CKeepAlive, COpenScreen, CParticle, CPlayerAbilities, CPlayerInfoUpdate, CPlayerPosition,
CPlayerSpawnPosition, CRespawn, CSetContainerContent, CSetContainerProperty, CSetContainerSlot,
CSetCursorItem, CSetEquipment, CSetExperience, CSetHealth, CSetPlayerInventory,
CSetSelectedSlot, CSoundEffect, CStopSound, CSubtitle, CSystemChatMessage, CTabList,
CTitleAnimation, CTitleText, CUnloadChunk, CUpdateMobEffect, CUpdateTime, GameEvent, Metadata,
PlayerAction, PlayerInfoFlags, PreviousMessage,
CKeepAlive, 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, CUpdateMobEffect, CUpdateTime, GameEvent,
MapIcon, MapPatch, Metadata, PlayerAction, PlayerInfoFlags, PreviousMessage,
};
use pumpkin_protocol::java::server::play::{
SClickSlot, SContainerButtonClick, SRenameItem, SlotActionType,
@@ -1803,6 +1803,7 @@ impl Player {
// experience handling
self.tick_experience().await;
self.tick_health().await;
self.tick_maps(server).await;
// Timeout/keep alive handling
self.tick_client_load_timeout();
@@ -2825,6 +2826,67 @@ impl Player {
}
}
pub async fn tick_maps(&self, server: &Server) {
use pumpkin_data::data_component_impl::MapIdImpl;
use pumpkin_data::item::Item;
for hand in Hand::all() {
let item_in_hand = self.inventory.get_stack_in_hand(hand).await;
let stack = item_in_hand.lock().await;
if stack.item.id == Item::FILLED_MAP.id
&& let Some(map_id_comp) = stack.get_data_component::<MapIdImpl>() {
let map_id = map_id_comp.id;
if let Some(map_data_arc) = server.map_manager.get_map(map_id) {
let mut map_data = map_data_arc.lock().await;
map_data.update(self).await;
let tick_count = self.tick_counter.load(Ordering::Relaxed);
if map_data.dirty || tick_count % 10 == 0 {
let scale = 1 << map_data.scale;
let pos = self.position();
let dx = pos.x - map_data.center_x as f64;
let dz = pos.z - map_data.center_z as f64;
let icon_x = (dx / scale as f64 * 2.0).clamp(-128.0, 127.0) as i8;
let icon_z = (dz / scale as f64 * 2.0).clamp(-128.0, 127.0) as i8;
let yaw = self.living_entity.entity.yaw.load();
let icon_direction =
((((yaw * 16.0 / 360.0).round() as i32 + 8) % 16 + 16) % 16) as i8;
let icons = [MapIcon {
icon_type: VarInt(0), // White pointer
x: icon_x,
z: icon_z,
direction: icon_direction,
display_name: None,
}];
let data = map_data.dirty.then(|| MapPatch {
columns: 128,
rows: 128,
x: 0,
z: 0,
data: &*map_data.colors,
});
self.client
.enqueue_packet(&CMapItemData {
map_id: VarInt(map_id),
scale: map_data.scale,
locked: map_data.locked,
icons: Some(&icons),
data,
})
.await;
map_data.dirty = false;
}
}
}
}
}
/// Sets the player's experience level and notifies the client.
pub async fn set_experience(&self, level: i32, progress: f32, points: i32) {
// TODO: These should be atomic together, not isolated; make a struct containing these. can cause ABA issues

View File

@@ -0,0 +1,75 @@
use crate::entity::player::Player;
use crate::item::ItemBehaviour;
use crate::item::ItemMetadata;
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::DataComponentImpl;
use pumpkin_data::data_component_impl::MapIdImpl;
use pumpkin_data::item::Item;
use pumpkin_data::item_stack::ItemStack;
use pumpkin_util::GameMode;
use std::any::Any;
use std::future::Future;
use std::pin::Pin;
pub struct MapItem;
impl ItemMetadata for MapItem {
fn ids() -> Box<[u16]> {
[Item::MAP.id].into()
}
}
impl ItemBehaviour for MapItem {
fn normal_use<'a>(
&'a self,
_item: &'a Item,
player: &'a Player,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let server = player.world().server.upgrade().unwrap();
let inventory = player.inventory();
let main_hand_item = inventory.held_item();
let off_hand_item = inventory.off_hand_item().await;
let mut hand_stack = main_hand_item.lock().await;
let found = if !hand_stack.is_empty() && hand_stack.item.id == Item::MAP.id {
true
} else {
drop(hand_stack);
hand_stack = off_hand_item.lock().await;
!hand_stack.is_empty() && hand_stack.item.id == Item::MAP.id
};
if found {
let map_id = server.next_map_id();
let _ = server.map_manager.create_map(
map_id,
player.world().dimension,
player.position().x as i32,
player.position().z as i32,
0, // Default scale
);
let mut filled_map = ItemStack::new(1, &Item::FILLED_MAP);
filled_map.patch.push((
DataComponent::MapId,
Some(MapIdImpl { id: map_id }.to_dyn()),
));
let gamemode = player.gamemode.load();
if hand_stack.item_count == 1 && gamemode != GameMode::Creative {
*hand_stack = filled_map;
} else {
hand_stack.decrement_unless_creative(gamemode, 1);
drop(hand_stack);
inventory.offer_or_drop_stack(filled_map, player).await;
}
}
})
}
fn as_any(&self) -> &dyn Any {
self
}
}

View File

@@ -16,6 +16,7 @@ pub mod honeycomb;
pub mod ignite;
pub mod ink_sac;
pub mod mace;
pub mod map;
pub mod minecart;
pub mod name_tag;
pub mod potions;
@@ -30,6 +31,7 @@ use crate::item::items::armor_stand::ArmorStandItem;
use crate::item::items::boat::BoatItem;
use crate::item::items::end_crystal::EndCrystalItem;
use crate::item::items::firework_rocket::FireworkRocketItem;
use crate::item::items::map::MapItem;
use crate::item::items::minecart::MinecartItem;
use crate::item::items::name_tag::NameTagItem;
use crate::item::items::spawn_egg::SpawnEggItem;
@@ -87,6 +89,7 @@ pub fn default_registry() -> Arc<ItemRegistry> {
manager.register(EnderPearlItem);
manager.register(FireChargeItem);
manager.register(DyeItem);
manager.register(MapItem);
manager.register(FireworkRocketItem);
manager.register(InkSacItem);
manager.register(GlowingInkSacItem);

View File

@@ -12,7 +12,10 @@ use crate::plugin::player::player_login::PlayerLoginEvent;
use crate::plugin::server::server_broadcast::ServerBroadcastEvent;
use crate::server::tick_rate_manager::ServerTickRateManager;
use crate::world::custom_bossbar::CustomBossbars;
use crate::{command::node::dispatcher::CommandDispatcher, entity::player::Player, world::World};
use crate::{
command::node::dispatcher::CommandDispatcher, entity::player::Player, world::World,
world::map::MapManager,
};
use arc_swap::ArcSwap;
use connection_cache::{CachedBranding, CachedStatus};
use key_store::KeyStore;
@@ -94,11 +97,15 @@ pub struct Server {
pub dimensions: Vec<Dimension>,
/// Assigns unique IDs to containers.
container_id: AtomicU32,
/// Assigns unique IDs to maps.
map_id: AtomicI32,
/// Mojang's public keys, used for chat session signing
/// Pulled from Mojang API on startup
pub mojang_public_keys: ArcSwap<Vec<RsaPublicKey>>,
/// The server's custom bossbars
pub bossbars: Mutex<CustomBossbars>,
/// Manages all maps on the server
pub map_manager: MapManager,
/// The default gamemode when a player joins the server (reset every restart)
pub defaultgamemode: Mutex<DefaultGamemode>,
/// Manages player data storage
@@ -214,6 +221,7 @@ impl Server {
))),
permission_registry,
container_id: 0.into(),
map_id: level_info.load().map_id.into(),
worlds: ArcSwap::from_pointee(vec![]),
dimensions: vec![
Dimension::OVERWORLD,
@@ -228,6 +236,7 @@ impl Server {
listing,
branding: CachedBranding::new(),
bossbars: Mutex::new(CustomBossbars::new()),
map_manager: MapManager::new(),
defaultgamemode,
player_data_storage,
white_list,
@@ -761,6 +770,17 @@ impl Server {
self.container_id.fetch_add(1, Ordering::SeqCst)
}
/// Generates a new map id.
pub fn next_map_id(&self) -> i32 {
let id = self.map_id.fetch_add(1, Ordering::SeqCst);
self.level_info.rcu(|level_info| {
let mut new_level_info = (**level_info).clone();
new_level_info.map_id = self.map_id.load(Ordering::SeqCst);
new_level_info
});
id
}
pub fn get_branding(&self) -> CPluginMessage<'_> {
self.branding.get_branding()
}

134
pumpkin/src/world/map.rs Normal file
View File

@@ -0,0 +1,134 @@
use crate::entity::player::Player;
use dashmap::DashMap;
use pumpkin_data::dimension::Dimension;
use pumpkin_util::math::{position::BlockPos, vector2::Vector2};
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct MapManager {
pub maps: DashMap<i32, Arc<Mutex<MapData>>>,
}
impl Default for MapManager {
fn default() -> Self {
Self::new()
}
}
impl MapManager {
#[must_use]
pub fn new() -> Self {
Self {
maps: DashMap::new(),
}
}
#[must_use]
pub fn get_map(&self, id: i32) -> Option<Arc<Mutex<MapData>>> {
self.maps.get(&id).map(|m| m.clone())
}
#[must_use]
pub fn create_map(
&self,
id: i32,
dimension: Dimension,
x: i32,
z: i32,
scale: i8,
) -> Arc<Mutex<MapData>> {
let map = Arc::new(Mutex::new(MapData::new(dimension, x, z, scale)));
self.maps.insert(id, map.clone());
map
}
}
pub struct MapData {
pub scale: i8,
pub locked: bool,
pub dimension: Dimension,
pub center_x: i32,
pub center_z: i32,
pub colors: Box<[u8; 128 * 128]>,
pub dirty: bool,
pub fully_updated: bool,
}
impl MapData {
#[must_use]
pub fn new(dimension: Dimension, x: i32, z: i32, scale: i8) -> Self {
Self {
scale,
locked: false,
dimension,
center_x: x,
center_z: z,
colors: Box::new([0; 128 * 128]),
dirty: true,
fully_updated: false,
}
}
pub fn set_color(&mut self, x: usize, z: usize, color: u8) {
if x < 128 && z < 128 {
let idx = z * 128 + x;
if self.colors[idx] != color {
self.colors[idx] = color;
self.dirty = true;
}
}
}
pub async fn update(&mut self, player: &Player) {
let world = player.world();
let scale = 1 << self.scale;
let center_x = self.center_x;
let center_z = self.center_z;
let player_pos = player.position();
let player_x = player_pos.x as i32;
let player_z = player_pos.z as i32;
let start_img_x = ((player_x - center_x) / scale + 64).clamp(0, 127) as usize;
let start_img_z = ((player_z - center_z) / scale + 64).clamp(0, 127) as usize;
let radius = 16;
let (range_x, range_z) = if self.fully_updated {
(
(start_img_x.saturating_sub(radius))..(start_img_x + radius).min(128),
(start_img_z.saturating_sub(radius))..(start_img_z + radius).min(128),
)
} else {
self.fully_updated = true;
(0..128, 0..128)
};
for img_x in range_x {
let mut prev_y = -1;
for img_z in range_z.clone() {
let world_x = (img_x as i32 - 64) * scale + center_x;
let world_z = (img_z as i32 - 64) * scale + center_z;
let top_y = world.get_top_block(Vector2::new(world_x, world_z)).await;
let block = world
.get_block(&BlockPos::new(world_x, top_y, world_z))
.await;
let color_base = block.map_color;
let mut brightness = 2; // Normal
if prev_y != -1 {
if top_y > prev_y {
brightness = 3; // High
} else if top_y < prev_y {
brightness = 1; // Low
}
}
prev_y = top_y;
let color = color_base * 4 + brightness;
self.set_color(img_x, img_z, color);
}
}
}
}

View File

@@ -14,6 +14,7 @@ use tracing::{debug, error, info, trace, warn};
pub mod chunker;
pub mod explosion;
pub mod loot;
pub mod map;
pub mod portal;
pub mod time;