Anvil Rework (#367)

## Description

Solidify the design of `valence_anvil` so that most of the boilerplate
in the anvil example is eliminated. `AnvilLevel` is now a component of
`Instance` and automatically loads and unloads chunks as clients move
around. Events are used to communicate when chunks are loaded and
unloaded.

Also changes the system message API and introduces the `SendMessage`
trait.

Checks off a box in #288

### Known Issues
- Still no support for saving or entities.
- The handling of chunk `min_y` is wrong. I plan to fix this in an
upcoming redesign of instances and chunks.
- Uses one OS thread per anvil level. This could be improved with a
dedicated shared thread pool to parallelize the loading process.
However, it seems decently fast as it is.
- Old benchmark is commented out.
- Could use some tests.
This commit is contained in:
Ryan Johnson 2023-06-15 14:11:37 -07:00 committed by GitHub
parent 61f2279831
commit 2ed5a8840d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 564 additions and 333 deletions

View file

@ -40,6 +40,7 @@ glam = "0.23.0"
heck = "0.4.0"
hmac = "0.12.1"
indexmap = "1.9.3"
lru = "0.10.0"
noise = "0.8.2"
num = "0.4.0"
num-bigint = "0.4.3"

View file

@ -10,7 +10,7 @@ Ignoring transitive dependencies and `valence_core`, the dependency graph can be
```mermaid
graph TD
network --> client
network --> client
client --> instance
biome --> registry
dimension --> registry
@ -19,7 +19,7 @@ graph TD
instance --> entity
player_list --> client
inventory --> client
anvil --> instance
anvil --> client
entity --> block
advancement --> client
world_border --> client

View file

@ -1,3 +1,4 @@
/*
use std::fs::create_dir_all;
use std::hint::black_box;
use std::path::{Path, PathBuf};
@ -133,3 +134,4 @@ fn get_world_asset(
Ok(final_path)
}
*/

View file

@ -10,7 +10,7 @@ mod var_long;
criterion_group! {
benches,
anvil::load,
// anvil::load,
block::block,
decode_array::decode_array,
idle::idle_update,

View file

@ -1,80 +1,44 @@
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::path::PathBuf;
use std::thread;
use clap::Parser;
use flume::{Receiver, Sender};
use tracing::warn;
use valence::anvil::{AnvilChunk, AnvilWorld};
use valence::prelude::*;
use valence_anvil::{AnvilLevel, ChunkLoadEvent, ChunkLoadStatus};
use valence_client::message::SendMessage;
const SPAWN_POS: DVec3 = DVec3::new(0.0, 256.0, 0.0);
const SECTION_COUNT: usize = 24;
#[derive(Parser)]
#[derive(Parser, Resource)]
#[clap(author, version, about)]
struct Cli {
/// The path to a Minecraft world save containing a `region` subdirectory.
path: PathBuf,
}
#[derive(Resource)]
struct GameState {
/// Chunks that need to be generated. Chunks without a priority have already
/// been sent to the anvil thread.
pending: HashMap<ChunkPos, Option<Priority>>,
sender: Sender<ChunkPos>,
receiver: Receiver<(ChunkPos, Chunk)>,
}
/// The order in which chunks should be processed by anvil worker. Smaller
/// values are sent first.
type Priority = u64;
pub fn main() {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let dir = cli.path;
if !dir.exists() {
eprintln!("Directory `{}` does not exist. Exiting.", dir.display());
return;
} else if !dir.is_dir() {
eprintln!("`{}` is not a directory. Exiting.", dir.display());
if !cli.path.exists() {
eprintln!(
"Directory `{}` does not exist. Exiting.",
cli.path.display()
);
return;
}
let anvil = AnvilWorld::new(dir);
let (finished_sender, finished_receiver) = flume::unbounded();
let (pending_sender, pending_receiver) = flume::unbounded();
// Process anvil chunks in a different thread to avoid blocking the main tick
// loop.
thread::spawn(move || anvil_worker(pending_receiver, finished_sender, anvil));
let game_state = GameState {
pending: HashMap::new(),
sender: pending_sender,
receiver: finished_receiver,
};
if !cli.path.is_dir() {
eprintln!("`{}` is not a directory. Exiting.", cli.path.display());
return;
}
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(game_state)
.insert_resource(cli)
.add_startup_system(setup)
.add_systems(
(
init_clients,
remove_unviewed_chunks,
update_client_views,
send_recv_chunks,
)
.chain(),
)
.add_system(despawn_disconnected_clients)
.add_systems((init_clients, handle_chunk_loads).chain())
.add_system(display_loaded_chunk_count)
.run();
}
@ -83,9 +47,24 @@ fn setup(
dimensions: Res<DimensionTypeRegistry>,
biomes: Res<BiomeRegistry>,
server: Res<Server>,
cli: Res<Cli>,
) {
let instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
commands.spawn(instance);
let mut level = AnvilLevel::new(&cli.path, &biomes);
// Force a 16x16 area of chunks around the origin to be loaded at all times,
// similar to spawn chunks in vanilla. This isn't necessary, but it is done to
// demonstrate that it is possible.
for z in -8..8 {
for x in -8..8 {
let pos = ChunkPos::new(x, z);
level.ignored_chunks.insert(pos);
level.force_chunk_load(pos);
}
}
commands.spawn((instance, level));
}
fn init_clients(
@ -100,103 +79,44 @@ fn init_clients(
}
}
fn remove_unviewed_chunks(mut instances: Query<&mut Instance>) {
instances
.single_mut()
.retain_chunks(|_, chunk| chunk.is_viewed_mut());
}
fn update_client_views(
mut instances: Query<&mut Instance>,
mut clients: Query<(&mut Client, View, OldView)>,
mut state: ResMut<GameState>,
fn handle_chunk_loads(
mut events: EventReader<ChunkLoadEvent>,
mut instances: Query<&mut Instance, With<AnvilLevel>>,
) {
let instance = instances.single_mut();
let mut inst = instances.single_mut();
for (client, view, old_view) in &mut clients {
let view = view.get();
let queue_pos = |pos| {
if instance.chunk(pos).is_none() {
match state.pending.entry(pos) {
Entry::Occupied(mut oe) => {
if let Some(priority) = oe.get_mut() {
let dist = view.pos.distance_squared(pos);
*priority = (*priority).min(dist);
}
}
Entry::Vacant(ve) => {
let dist = view.pos.distance_squared(pos);
ve.insert(Some(dist));
}
}
for event in events.iter() {
match &event.status {
ChunkLoadStatus::Success { .. } => {
// The chunk was inserted into the world. Nothing for us to do.
}
};
// Queue all the new chunks in the view to be sent to the anvil worker.
if client.is_added() {
view.iter().for_each(queue_pos);
} else {
let old_view = old_view.get();
if old_view != view {
view.diff(old_view).for_each(queue_pos);
ChunkLoadStatus::Empty => {
// There's no chunk here so let's insert an empty chunk. If we were doing
// terrain generation we would prepare that here.
inst.insert_chunk(event.pos, Chunk::default());
}
ChunkLoadStatus::Failed(e) => {
// Something went wrong.
eprintln!(
"failed to load chunk at ({}, {}): {e:#}",
event.pos.x, event.pos.z
);
inst.insert_chunk(event.pos, Chunk::default());
}
}
}
}
fn send_recv_chunks(mut instances: Query<&mut Instance>, state: ResMut<GameState>) {
let mut instance = instances.single_mut();
let state = state.into_inner();
// Display the number of loaded chunks in the action bar of all clients.
fn display_loaded_chunk_count(mut instances: Query<&mut Instance>, mut last_count: Local<usize>) {
let mut inst = instances.single_mut();
// Insert the chunks that are finished loading into the instance.
for (pos, chunk) in state.receiver.drain() {
instance.insert_chunk(pos, chunk);
assert!(state.pending.remove(&pos).is_some());
}
let cnt = inst.chunks().count();
// Collect all the new chunks that need to be loaded this tick.
let mut to_send = vec![];
for (pos, priority) in &mut state.pending {
if let Some(pri) = priority.take() {
to_send.push((pri, pos));
}
}
// Sort chunks by ascending priority.
to_send.sort_unstable_by_key(|(pri, _)| *pri);
// Send the sorted chunks to be loaded.
for (_, pos) in to_send {
let _ = state.sender.try_send(*pos);
if *last_count != cnt {
*last_count = cnt;
inst.send_action_bar_message(
"Chunk Count: ".into_text() + (cnt as i32).color(Color::LIGHT_PURPLE),
);
}
}
fn anvil_worker(
receiver: Receiver<ChunkPos>,
sender: Sender<(ChunkPos, Chunk)>,
mut world: AnvilWorld,
) {
while let Ok(pos) = receiver.recv() {
match get_chunk(pos, &mut world) {
Ok(chunk) => {
if let Some(chunk) = chunk {
let _ = sender.try_send((pos, chunk));
}
}
Err(e) => warn!("Failed to get chunk at ({}, {}): {e:#}.", pos.x, pos.z),
}
}
}
fn get_chunk(pos: ChunkPos, world: &mut AnvilWorld) -> anyhow::Result<Option<Chunk>> {
let Some(AnvilChunk { data, .. }) = world.read_chunk(pos.x, pos.z)? else {
return Ok(None)
};
let mut chunk = Chunk::new(SECTION_COUNT);
valence_anvil::to_valence(&data, &mut chunk, 4, |_| BiomeId::default())?;
Ok(Some(chunk))
}

View file

@ -2,8 +2,8 @@
use valence::nbt::{compound, List};
use valence::prelude::*;
use valence_client::chat::ChatMessageEvent;
use valence_client::interact_block::InteractBlockEvent;
use valence_client::message::ChatMessageEvent;
const FLOOR_Y: i32 = 64;
const SIGN_POS: [i32; 3] = [3, FLOOR_Y + 1, 2];

View file

@ -3,6 +3,7 @@
use valence::inventory::HeldItem;
use valence::prelude::*;
use valence_client::interact_block::InteractBlockEvent;
use valence_client::message::SendMessage;
const SPAWN_Y: i32 = 64;
@ -55,7 +56,7 @@ fn init_clients(
loc.0 = instances.single();
pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]);
client.send_message("Welcome to Valence! Build something cool.".italic());
client.send_chat_message("Welcome to Valence! Build something cool.".italic());
}
}

View file

@ -3,6 +3,7 @@
use std::mem;
use valence::prelude::*;
use valence_client::message::SendMessage;
const BOARD_MIN_X: i32 = -30;
const BOARD_MAX_X: i32 = 30;
@ -74,8 +75,8 @@ fn init_clients(
instances: Query<Entity, With<Instance>>,
) {
for (mut client, mut loc, mut pos) in &mut clients {
client.send_message("Welcome to Conway's game of life in Minecraft!".italic());
client.send_message(
client.send_chat_message("Welcome to Conway's game of life in Minecraft!".italic());
client.send_chat_message(
"Sneak to toggle running the simulation and the left mouse button to bring blocks to \
life."
.italic(),

View file

@ -1,6 +1,7 @@
#![allow(clippy::type_complexity)]
use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_client::status::RequestRespawnEvent;
const SPAWN_Y: i32 = 64;
@ -58,7 +59,7 @@ fn init_clients(
pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]);
has_respawn_screen.0 = true;
client.send_message(
client.send_chat_message(
"Welcome to Valence! Sneak to die in the game (but not in real life).".italic(),
);
}

View file

@ -4,6 +4,7 @@ use bevy_app::App;
use bevy_ecs::prelude::Entity;
use rand::Rng;
use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_entity::entity::NameVisible;
use valence_entity::hoglin::HoglinEntityBundle;
use valence_entity::pig::PigEntityBundle;
@ -53,7 +54,7 @@ fn init_clients(
loc.0 = instances.single();
pos.set([0.5, 65.0, 0.5]);
*game_mode = GameMode::Creative;
client.send_message("To spawn an entity, press shift. F3 + B to activate hitboxes");
client.send_chat_message("To spawn an entity, press shift. F3 + B to activate hitboxes");
}
}

View file

@ -7,6 +7,7 @@ use rand::seq::SliceRandom;
use rand::Rng;
use valence::prelude::*;
use valence::protocol::packet::sound::{Sound, SoundCategory};
use valence_client::message::SendMessage;
const START_POS: BlockPos = BlockPos::new(0, 100, 0);
const VIEW_DIST: u8 = 10;
@ -66,7 +67,7 @@ fn init_clients(
is_flat.0 = true;
*game_mode = GameMode::Adventure;
client.send_message("Welcome to epic infinite parkour game!".italic());
client.send_chat_message("Welcome to epic infinite parkour game!".italic());
let state = GameState {
blocks: VecDeque::new(),
@ -96,7 +97,7 @@ fn reset_clients(
if out_of_bounds || state.is_added() {
if out_of_bounds && !state.is_added() {
client.send_message(
client.send_chat_message(
"Your score was ".italic()
+ state
.score

View file

@ -3,6 +3,7 @@
use rand::Rng;
use valence::player_list::{DisplayName, PlayerListEntryBundle};
use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_client::Ping;
const SPAWN_Y: i32 = 64;
@ -62,7 +63,7 @@ fn init_clients(
loc.0 = instances.single();
*game_mode = GameMode::Creative;
client.send_message(
client.send_chat_message(
"Please open your player list (tab key)."
.italic()
.color(Color::WHITE),

View file

@ -3,6 +3,7 @@
use valence::entity::player::PlayerEntityBundle;
use valence::entity::sheep::SheepEntityBundle;
use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_client::resource_pack::{ResourcePackStatus, ResourcePackStatusEvent};
const SPAWN_Y: i32 = 64;
@ -57,7 +58,7 @@ fn init_clients(
for (entity, uuid, mut client, mut game_mode) in &mut clients {
*game_mode = GameMode::Creative;
client.send_message("Hit the sheep to prompt for the resource pack.".italic());
client.send_chat_message("Hit the sheep to prompt for the resource pack.".italic());
commands.entity(entity).insert(PlayerEntityBundle {
location: Location(instances.single()),
@ -91,17 +92,18 @@ fn on_resource_pack_status(
if let Ok(mut client) = clients.get_mut(event.client) {
match event.status {
ResourcePackStatus::Accepted => {
client.send_message("Resource pack accepted.".color(Color::GREEN));
client.send_chat_message("Resource pack accepted.".color(Color::GREEN));
}
ResourcePackStatus::Declined => {
client.send_message("Resource pack declined.".color(Color::RED));
client.send_chat_message("Resource pack declined.".color(Color::RED));
}
ResourcePackStatus::FailedDownload => {
client.send_message("Resource pack failed to download.".color(Color::RED));
client.send_chat_message("Resource pack failed to download.".color(Color::RED));
}
ResourcePackStatus::SuccessfullyLoaded => {
client
.send_message("Resource pack successfully downloaded.".color(Color::BLUE));
client.send_chat_message(
"Resource pack successfully downloaded.".color(Color::BLUE),
);
}
}
};

View file

@ -1,6 +1,7 @@
#![allow(clippy::type_complexity)]
use valence::prelude::*;
use valence_client::message::SendMessage;
const SPAWN_Y: i32 = 64;
@ -47,16 +48,17 @@ fn init_clients(
loc.0 = instances.single();
*game_mode = GameMode::Creative;
client.send_message("Welcome to the text example.".bold());
client
.send_message("The following examples show ways to use the different text components.");
client.send_chat_message("Welcome to the text example.".bold());
client.send_chat_message(
"The following examples show ways to use the different text components.",
);
// Text examples
client.send_message("\nText");
client.send_message(" - ".into_text() + Text::text("Plain text"));
client.send_message(" - ".into_text() + Text::text("Styled text").italic());
client.send_message(" - ".into_text() + Text::text("Colored text").color(Color::GOLD));
client.send_message(
client.send_chat_message("\nText");
client.send_chat_message(" - ".into_text() + Text::text("Plain text"));
client.send_chat_message(" - ".into_text() + Text::text("Styled text").italic());
client.send_chat_message(" - ".into_text() + Text::text("Colored text").color(Color::GOLD));
client.send_chat_message(
" - ".into_text()
+ Text::text("Colored and styled text")
.color(Color::GOLD)
@ -65,59 +67,61 @@ fn init_clients(
);
// Translated text examples
client.send_message("\nTranslated Text");
client.send_message(
client.send_chat_message("\nTranslated Text");
client.send_chat_message(
" - 'chat.type.advancement.task': ".into_text()
+ Text::translate(translation_key::CHAT_TYPE_ADVANCEMENT_TASK, []),
);
client.send_message(
client.send_chat_message(
" - 'chat.type.advancement.task' with slots: ".into_text()
+ Text::translate(
translation_key::CHAT_TYPE_ADVANCEMENT_TASK,
["arg1".into(), "arg2".into()],
),
);
client.send_message(
client.send_chat_message(
" - 'custom.translation_key': ".into_text()
+ Text::translate("custom.translation_key", []),
);
// Scoreboard value example
client.send_message("\nScoreboard Values");
client.send_message(" - Score: ".into_text() + Text::score("*", "objective", None));
client.send_message(
client.send_chat_message("\nScoreboard Values");
client.send_chat_message(" - Score: ".into_text() + Text::score("*", "objective", None));
client.send_chat_message(
" - Score with custom value: ".into_text()
+ Text::score("*", "objective", Some("value".into())),
);
// Entity names example
client.send_message("\nEntity Names (Selector)");
client.send_message(" - Nearest player: ".into_text() + Text::selector("@p", None));
client.send_message(" - Random player: ".into_text() + Text::selector("@r", None));
client.send_message(" - All players: ".into_text() + Text::selector("@a", None));
client.send_message(" - All entities: ".into_text() + Text::selector("@e", None));
client.send_message(
client.send_chat_message("\nEntity Names (Selector)");
client.send_chat_message(" - Nearest player: ".into_text() + Text::selector("@p", None));
client.send_chat_message(" - Random player: ".into_text() + Text::selector("@r", None));
client.send_chat_message(" - All players: ".into_text() + Text::selector("@a", None));
client.send_chat_message(" - All entities: ".into_text() + Text::selector("@e", None));
client.send_chat_message(
" - All entities with custom separator: ".into_text()
+ Text::selector("@e", Some(", ".into_text().color(Color::GOLD))),
);
// Keybind example
client.send_message("\nKeybind");
client.send_message(" - 'key.inventory': ".into_text() + Text::keybind("key.inventory"));
client.send_chat_message("\nKeybind");
client
.send_chat_message(" - 'key.inventory': ".into_text() + Text::keybind("key.inventory"));
// NBT examples
client.send_message("\nNBT");
client.send_message(
client.send_chat_message("\nNBT");
client.send_chat_message(
" - Block NBT: ".into_text() + Text::block_nbt("{}", "0 1 0", None, None),
);
client
.send_message(" - Entity NBT: ".into_text() + Text::entity_nbt("{}", "@a", None, None));
client.send_message(
client.send_chat_message(
" - Entity NBT: ".into_text() + Text::entity_nbt("{}", "@a", None, None),
);
client.send_chat_message(
" - Storage NBT: ".into_text()
+ Text::storage_nbt(ident!("storage.key"), "@a", None, None),
);
client.send_message(
client.send_chat_message(
"\n\n".into_text().bold().color(Color::GOLD)
+ "Scroll up to see the full example!".into_text().not_bold(),
);

View file

@ -1,11 +1,12 @@
use std::time::Duration;
use bevy_app::App;
use valence::client::chat::ChatMessageEvent;
use valence::client::despawn_disconnected_clients;
use valence::client::message::ChatMessageEvent;
use valence::inventory::HeldItem;
use valence::prelude::*;
use valence::world_border::*;
use valence_client::message::SendMessage;
const SPAWN_Y: i32 = 64;
@ -66,7 +67,7 @@ fn init_clients(
pos.set([0.5, SPAWN_Y as f64 + 1.0, 0.5]);
let pickaxe = Some(ItemStack::new(ItemKind::WoodenPickaxe, 1, None));
inv.set_slot(main_slot.slot(), pickaxe);
client.send_message("Break block to increase border size!");
client.send_chat_message("Break block to increase border size!");
}
}

View file

@ -154,7 +154,7 @@ impl PluginGroup for DefaultPlugins {
#[cfg(feature = "anvil")]
{
// No plugin... yet.
group = group.add(valence_anvil::AnvilPlugin);
}
#[cfg(feature = "advancement")]

View file

@ -1,8 +1,7 @@
[package]
name = "valence_anvil"
description = "A library for Minecraft's Anvil world format."
description = "Anvil world format support for Valence"
documentation.workspace = true
repository = "https://github.com/valence-rs/valence/tree/main/crates/valence_anvil"
readme = "README.md"
license.workspace = true
keywords = ["anvil", "minecraft", "deserialization"]
@ -10,12 +9,20 @@ version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
bevy_app.workspace = true
bevy_ecs.workspace = true
byteorder.workspace = true
flate2.workspace = true
flume.workspace = true
lru.workspace = true
num-integer.workspace = true
thiserror.workspace = true
tracing.workspace = true
valence_biome.workspace = true
valence_block.workspace = true
valence_client.workspace = true
valence_core.workspace = true
valence_entity.workspace = true
valence_instance.workspace = true
valence_nbt.workspace = true

View file

@ -17,99 +17,149 @@
clippy::dbg_macro
)]
use std::collections::btree_map::Entry;
use std::collections::BTreeMap;
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs::File;
use std::io;
use std::io::{ErrorKind, Read, Seek, SeekFrom};
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::thread;
use anyhow::{bail, ensure};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use byteorder::{BigEndian, ReadBytesExt};
use flate2::bufread::{GzDecoder, ZlibDecoder};
use thiserror::Error;
pub use to_valence::*;
use flume::{Receiver, Sender};
use lru::LruCache;
use tracing::warn;
use valence_biome::{BiomeId, BiomeRegistry};
use valence_client::{Client, OldView, UpdateClientsSet, View};
use valence_core::chunk_pos::ChunkPos;
use valence_core::ident::Ident;
use valence_entity::{Location, OldLocation};
use valence_instance::{Chunk, Instance};
use valence_nbt::Compound;
mod to_valence;
mod parse_chunk;
#[derive(Debug)]
pub struct AnvilWorld {
/// Path to the "region" subdirectory in the world root.
region_root: PathBuf,
// TODO: LRU cache for region file handles.
/// Maps region (x, z) positions to region files.
regions: BTreeMap<(i32, i32), Region>,
#[derive(Component, Debug)]
pub struct AnvilLevel {
/// Chunk worker state to be moved to another thread.
worker_state: Option<ChunkWorkerState>,
/// The set of chunk positions that should not be loaded or unloaded by
/// the anvil system.
///
/// This set is empty by default, but you can modify it at any time.
pub ignored_chunks: HashSet<ChunkPos>,
/// Chunks that need to be loaded. Chunks with `None` priority have already
/// been sent to the anvil thread.
pending: HashMap<ChunkPos, Option<Priority>>,
/// Sender for the chunk worker thread.
sender: Sender<ChunkPos>,
/// Receiver for the chunk worker thread.
receiver: Receiver<(ChunkPos, WorkerResult)>,
}
#[derive(Clone, PartialEq, Debug)]
pub struct AnvilChunk {
/// This chunk's NBT data.
pub data: Compound,
/// The time this chunk was last modified measured in seconds since the
/// epoch.
pub timestamp: u32,
}
type WorkerResult = anyhow::Result<Option<(Chunk, AnvilChunk)>>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ReadChunkError {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Nbt(#[from] valence_nbt::binary::Error),
#[error("invalid chunk sector offset")]
BadSectorOffset,
#[error("invalid chunk size")]
BadChunkSize,
#[error("unknown compression scheme number of {0}")]
UnknownCompressionScheme(u8),
#[error("not all chunk NBT data was read")]
IncompleteNbtRead,
}
#[derive(Debug)]
struct Region {
file: File,
/// The first 8 KiB in the file.
header: [u8; SECTOR_SIZE * 2],
}
const SECTOR_SIZE: usize = 4096;
impl AnvilWorld {
pub fn new(world_root: impl Into<PathBuf>) -> Self {
impl AnvilLevel {
pub fn new(world_root: impl Into<PathBuf>, biomes: &BiomeRegistry) -> Self {
let mut region_root = world_root.into();
region_root.push("region");
let (pending_sender, pending_receiver) = flume::unbounded();
let (finished_sender, finished_receiver) = flume::bounded(4096);
Self {
region_root,
regions: BTreeMap::new(),
worker_state: Some(ChunkWorkerState {
regions: LruCache::new(LRU_CACHE_SIZE),
region_root,
sender: finished_sender,
receiver: pending_receiver,
decompress_buf: vec![],
biome_to_id: biomes
.iter()
.map(|(id, name, _)| (name.to_string_ident(), id))
.collect(),
section_count: 0, // Assigned later.
}),
ignored_chunks: HashSet::new(),
pending: HashMap::new(),
sender: pending_sender,
receiver: finished_receiver,
}
}
/// Reads a chunk from the file system with the given chunk coordinates. If
/// no chunk exists at the position, then `None` is returned.
pub fn read_chunk(
&mut self,
chunk_x: i32,
chunk_z: i32,
) -> Result<Option<AnvilChunk>, ReadChunkError> {
let region_x = chunk_x.div_euclid(32);
let region_z = chunk_z.div_euclid(32);
let region = match self.regions.entry((region_x, region_z)) {
/// Forces a chunk to be loaded at a specific position in this world. This
/// will bypass [`AnvilLevel::ignored_chunks`].
/// Note that the chunk will be unloaded next tick unless it has been added
/// to [`AnvilLevel::ignored_chunks`] or it is in view of a client.
///
/// This has no effect if a chunk at the position is already present.
pub fn force_chunk_load(&mut self, pos: ChunkPos) {
match self.pending.entry(pos) {
Entry::Occupied(oe) => {
// If the chunk is already scheduled to load but hasn't been sent to the chunk
// worker yet, then give it the highest priority.
if let Some(priority) = oe.into_mut() {
*priority = 0;
}
}
Entry::Vacant(ve) => {
// Load the region file if it exists. Otherwise, the chunk is considered absent.
ve.insert(Some(0));
}
}
}
}
// TODO: Add tombstone for missing region file in `regions`.
const LRU_CACHE_SIZE: NonZeroUsize = match NonZeroUsize::new(256) {
Some(n) => n,
None => unreachable!(),
};
/// The order in which chunks should be processed by the anvil worker. Smaller
/// values are sent first.
type Priority = u64;
#[derive(Debug)]
struct ChunkWorkerState {
/// Region files. An LRU cache is used to limit the number of open file
/// handles.
regions: LruCache<RegionPos, RegionEntry>,
/// Path to the "region" subdirectory in the world root.
region_root: PathBuf,
/// Sender of finished chunks.
sender: Sender<(ChunkPos, WorkerResult)>,
/// Receiver of pending chunks.
receiver: Receiver<ChunkPos>,
/// Scratch buffer for decompression.
decompress_buf: Vec<u8>,
/// Mapping of biome names to their biome ID.
biome_to_id: BTreeMap<Ident<String>, BiomeId>,
/// Number of chunk sections in the instance.
section_count: usize,
}
impl ChunkWorkerState {
fn get_chunk(&mut self, pos: ChunkPos) -> anyhow::Result<Option<AnvilChunk>> {
let region_x = pos.x.div_euclid(32);
let region_z = pos.z.div_euclid(32);
let region = match self.regions.get_mut(&(region_x, region_z)) {
Some(RegionEntry::Occupied(region)) => region,
Some(RegionEntry::Vacant) => return Ok(None),
None => {
let path = self
.region_root
.join(format!("r.{region_x}.{region_z}.mca"));
let mut file = match File::options().read(true).write(true).open(path) {
Ok(file) => file,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
Err(e) if e.kind() == ErrorKind::NotFound => {
self.regions.put((region_x, region_z), RegionEntry::Vacant);
return Ok(None);
}
Err(e) => return Err(e.into()),
};
@ -117,12 +167,19 @@ impl AnvilWorld {
file.read_exact(&mut header)?;
ve.insert(Region { file, header })
// TODO: this is ugly.
let res = self.regions.get_or_insert_mut((region_x, region_z), || {
RegionEntry::Occupied(Region { file, header })
});
match res {
RegionEntry::Occupied(r) => r,
RegionEntry::Vacant => unreachable!(),
}
}
Entry::Occupied(oe) => oe.into_mut(),
};
let chunk_idx = (chunk_x.rem_euclid(32) + chunk_z.rem_euclid(32) * 32) as usize;
let chunk_idx = (pos.x.rem_euclid(32) + pos.z.rem_euclid(32) * 32) as usize;
let location_bytes = (&region.header[chunk_idx * 4..]).read_u32::<BigEndian>()?;
let timestamp = (&region.header[chunk_idx * 4 + SECTOR_SIZE..]).read_u32::<BigEndian>()?;
@ -135,11 +192,9 @@ impl AnvilWorld {
let sector_offset = (location_bytes >> 8) as u64;
let sector_count = (location_bytes & 0xff) as usize;
if sector_offset < 2 {
// If the sector offset was <2, then the chunk data would be inside the region
// header. That doesn't make any sense.
return Err(ReadChunkError::BadSectorOffset);
}
// If the sector offset was <2, then the chunk data would be inside the region
// header. That doesn't make any sense.
ensure!(sector_offset >= 2, "invalid chunk sector offset");
// Seek to the beginning of the chunk's data.
region
@ -148,44 +203,267 @@ impl AnvilWorld {
let exact_chunk_size = region.file.read_u32::<BigEndian>()? as usize;
if exact_chunk_size > sector_count * SECTOR_SIZE {
// Sector size of this chunk must always be >= the exact size.
return Err(ReadChunkError::BadChunkSize);
}
// size of this chunk in sectors must always be >= the exact size.
ensure!(
sector_count * SECTOR_SIZE >= exact_chunk_size,
"invalid chunk size"
);
let mut data_buf = vec![0; exact_chunk_size].into_boxed_slice();
region.file.read_exact(&mut data_buf)?;
let mut r = data_buf.as_ref();
let mut decompress_buf = vec![];
self.decompress_buf.clear();
// What compression does the chunk use?
let mut nbt_slice = match r.read_u8()? {
// GZip
1 => {
let mut z = GzDecoder::new(r);
z.read_to_end(&mut decompress_buf)?;
decompress_buf.as_slice()
z.read_to_end(&mut self.decompress_buf)?;
self.decompress_buf.as_slice()
}
// Zlib
2 => {
let mut z = ZlibDecoder::new(r);
z.read_to_end(&mut decompress_buf)?;
decompress_buf.as_slice()
z.read_to_end(&mut self.decompress_buf)?;
self.decompress_buf.as_slice()
}
// Uncompressed
3 => r,
// Unknown
b => return Err(ReadChunkError::UnknownCompressionScheme(b)),
b => bail!("unknown compression scheme number of {b}"),
};
let (data, _) = Compound::from_binary(&mut nbt_slice)?;
if !nbt_slice.is_empty() {
return Err(ReadChunkError::IncompleteNbtRead);
}
ensure!(nbt_slice.is_empty(), "not all chunk NBT data was read");
Ok(Some(AnvilChunk { data, timestamp }))
}
}
struct AnvilChunk {
data: Compound,
timestamp: u32,
}
/// X and Z positions of a region.
type RegionPos = (i32, i32);
#[allow(clippy::large_enum_variant)] // We're not moving this around.
#[derive(Debug)]
enum RegionEntry {
/// There is a region file loaded here.
Occupied(Region),
/// There is no region file at this position. Don't try to read it from the
/// filesystem again.
Vacant,
}
#[derive(Debug)]
struct Region {
file: File,
/// The first 8 KiB in the file.
header: [u8; SECTOR_SIZE * 2],
}
const SECTOR_SIZE: usize = 4096;
pub struct AnvilPlugin;
impl Plugin for AnvilPlugin {
fn build(&self, app: &mut App) {
app.add_event::<ChunkLoadEvent>()
.add_event::<ChunkUnloadEvent>()
.add_system(remove_unviewed_chunks.in_base_set(CoreSet::PreUpdate))
.add_systems(
(init_anvil, update_client_views, send_recv_chunks)
.chain()
.in_base_set(CoreSet::PostUpdate)
.before(UpdateClientsSet),
);
}
}
fn init_anvil(mut query: Query<(&mut AnvilLevel, &Instance), Added<AnvilLevel>>) {
for (mut level, inst) in &mut query {
if let Some(mut state) = level.worker_state.take() {
state.section_count = inst.section_count();
thread::spawn(move || anvil_worker(state));
}
}
}
/// Removes all chunks no longer viewed by clients.
///
/// This needs to run in `PreUpdate` where the chunk viewer counts have been
/// updated from the previous tick.
fn remove_unviewed_chunks(
mut instances: Query<(Entity, &mut Instance, &AnvilLevel)>,
mut unload_events: EventWriter<ChunkUnloadEvent>,
) {
for (entity, mut inst, anvil) in &mut instances {
inst.retain_chunks(|pos, chunk| {
if chunk.is_viewed_mut() || anvil.ignored_chunks.contains(&pos) {
true
} else {
unload_events.send(ChunkUnloadEvent {
instance: entity,
pos,
});
false
}
});
}
}
fn update_client_views(
clients: Query<(&Location, Ref<OldLocation>, View, OldView), With<Client>>,
mut instances: Query<(&Instance, &mut AnvilLevel)>,
) {
for (loc, old_loc, view, old_view) in &clients {
let view = view.get();
let old_view = old_view.get();
if loc != &*old_loc || view != old_view || old_loc.is_added() {
let Ok((inst, mut anvil)) = instances.get_mut(loc.0) else {
continue
};
let queue_pos = |pos| {
if !anvil.ignored_chunks.contains(&pos) && inst.chunk(pos).is_none() {
// Chunks closer to clients are prioritized.
match anvil.pending.entry(pos) {
Entry::Occupied(mut oe) => {
if let Some(priority) = oe.get_mut() {
let dist = view.pos.distance_squared(pos);
*priority = (*priority).min(dist);
}
}
Entry::Vacant(ve) => {
let dist = view.pos.distance_squared(pos);
ve.insert(Some(dist));
}
}
}
};
// Queue all the new chunks in the view to be sent to the anvil worker.
if old_loc.is_added() {
view.iter().for_each(queue_pos);
} else {
view.diff(old_view).for_each(queue_pos);
}
}
}
}
fn send_recv_chunks(
mut instances: Query<(Entity, &mut Instance, &mut AnvilLevel)>,
mut to_send: Local<Vec<(Priority, ChunkPos)>>,
mut load_events: EventWriter<ChunkLoadEvent>,
) {
for (entity, mut inst, anvil) in &mut instances {
let anvil = anvil.into_inner();
// Insert the chunks that are finished loading into the instance and send load
// events.
for (pos, res) in anvil.receiver.drain() {
anvil.pending.remove(&pos);
let status = match res {
Ok(Some((chunk, AnvilChunk { data, timestamp }))) => {
inst.insert_chunk(pos, chunk);
ChunkLoadStatus::Success { data, timestamp }
}
Ok(None) => ChunkLoadStatus::Empty,
Err(e) => ChunkLoadStatus::Failed(e),
};
load_events.send(ChunkLoadEvent {
instance: entity,
pos,
status,
});
}
// Collect all the new chunks that need to be loaded this tick.
for (pos, priority) in &mut anvil.pending {
if let Some(pri) = priority.take() {
to_send.push((pri, *pos));
}
}
// Sort chunks by ascending priority.
to_send.sort_unstable_by_key(|(pri, _)| *pri);
// Send the sorted chunks to be loaded.
for (_, pos) in to_send.drain(..) {
let _ = anvil.sender.try_send(pos);
}
}
}
fn anvil_worker(mut state: ChunkWorkerState) {
while let Ok(pos) = state.receiver.recv() {
let res = get_chunk(pos, &mut state);
let _ = state.sender.send((pos, res));
}
fn get_chunk(pos: ChunkPos, state: &mut ChunkWorkerState) -> WorkerResult {
let Some(anvil_chunk) = state.get_chunk(pos)? else {
return Ok(None);
};
let mut chunk = Chunk::new(state.section_count);
// TODO: account for min_y correctly.
parse_chunk::parse_chunk(&anvil_chunk.data, &mut chunk, 4, |biome| {
state
.biome_to_id
.get(biome.as_str())
.copied()
.unwrap_or_default()
})?;
Ok(Some((chunk, anvil_chunk)))
}
}
/// An event sent by `valence_anvil` after an attempt to load a chunk is made.
#[derive(Debug)]
pub struct ChunkLoadEvent {
/// The [`Instance`] where the chunk is located.
pub instance: Entity,
/// The position of the chunk in the instance.
pub pos: ChunkPos,
pub status: ChunkLoadStatus,
}
#[derive(Debug)]
pub enum ChunkLoadStatus {
/// A new chunk was successfully loaded and inserted into the instance.
Success {
/// The raw chunk data of the new chunk.
data: Compound,
/// The time this chunk was last modified, measured in seconds since the
/// epoch.
timestamp: u32,
},
/// The Anvil level does not have a chunk at the position. No chunk was
/// loaded.
Empty,
/// An attempt was made to load the chunk, but something went wrong.
Failed(anyhow::Error),
}
/// An event sent by `valence_anvil` when a chunk is unloaded from an instance.
#[derive(Debug)]
pub struct ChunkUnloadEvent {
/// The [`Instance`] where the chunk was unloaded.
pub instance: Entity,
/// The position of the chunk that was unloaded.
pub pos: ChunkPos,
}

View file

@ -10,7 +10,7 @@ use valence_nbt::{Compound, List, Value};
#[derive(Clone, Debug, Error)]
#[non_exhaustive]
pub enum ToValenceError {
pub(crate) enum ParseChunkError {
#[error("missing chunk sections")]
MissingSections,
#[error("missing chunk section Y")]
@ -45,8 +45,6 @@ pub enum ToValenceError {
BadBiomePaletteLen,
#[error("biome name is not a valid resource identifier")]
BadBiomeName,
#[error("missing biome name")]
MissingBiomeName,
#[error("missing packed biome data in section")]
MissingBiomeData,
#[error("unexpected number of longs in biome data")]
@ -69,27 +67,24 @@ pub enum ToValenceError {
///
/// # Arguments
///
/// - `nbt`: The Anvil chunk to read from. This is usually the value returned by
/// [`AnvilWorld::read_chunk`].
/// - `nbt`: The raw Anvil chunk NBT to read from.
/// - `chunk`: The Valence chunk to write to.
/// - `sect_offset`: A constant to add to all sector Y positions in `nbt`. After
/// applying the offset, only the sectors in the range
/// `0..chunk.sector_count()` are written.
/// `0..chunk.section_count()` are written.
/// - `map_biome`: A function to map biome resource identifiers in the NBT data
/// to Valence [`BiomeId`]s.
///
/// [`AnvilWorld::read_chunk`]: crate::AnvilWorld::read_chunk
pub fn to_valence<F, const LOADED: bool>(
pub(crate) fn parse_chunk<F, const LOADED: bool>(
nbt: &Compound,
chunk: &mut Chunk<LOADED>,
sect_offset: i32,
mut map_biome: F,
) -> Result<(), ToValenceError>
) -> Result<(), ParseChunkError>
where
F: FnMut(Ident<&str>) -> BiomeId,
{
let Some(Value::List(List::Compound(sections))) = nbt.get("sections") else {
return Err(ToValenceError::MissingSections)
return Err(ParseChunkError::MissingSections)
};
let mut converted_block_palette = vec![];
@ -97,7 +92,7 @@ where
for section in sections {
let Some(Value::Byte(sect_y)) = section.get("Y") else {
return Err(ToValenceError::MissingSectionY)
return Err(ParseChunkError::MissingSectionY)
};
let adjusted_sect_y = *sect_y as i32 + sect_offset;
@ -108,26 +103,26 @@ where
}
let Some(Value::Compound(block_states)) = section.get("block_states") else {
return Err(ToValenceError::MissingBlockStates)
return Err(ParseChunkError::MissingBlockStates)
};
let Some(Value::List(List::Compound(palette))) = block_states.get("palette") else {
return Err(ToValenceError::MissingBlockPalette)
return Err(ParseChunkError::MissingBlockPalette)
};
if !(1..BLOCKS_PER_SECTION).contains(&palette.len()) {
return Err(ToValenceError::BadBlockPaletteLen);
return Err(ParseChunkError::BadBlockPaletteLen);
}
converted_block_palette.clear();
for block in palette {
let Some(Value::String(name)) = block.get("Name") else {
return Err(ToValenceError::MissingBlockName)
return Err(ParseChunkError::MissingBlockName)
};
let Some(block_kind) = BlockKind::from_str(ident_path(name)) else {
return Err(ToValenceError::UnknownBlockName(name.into()))
return Err(ParseChunkError::UnknownBlockName(name.into()))
};
let mut state = block_kind.to_state();
@ -135,15 +130,15 @@ where
if let Some(Value::Compound(properties)) = block.get("Properties") {
for (key, value) in properties {
let Value::String(value) = value else {
return Err(ToValenceError::BadPropValueType)
return Err(ParseChunkError::BadPropValueType)
};
let Some(prop_name) = PropName::from_str(key) else {
return Err(ToValenceError::UnknownPropName(key.into()))
return Err(ParseChunkError::UnknownPropName(key.into()))
};
let Some(prop_value) = PropValue::from_str(value) else {
return Err(ToValenceError::UnknownPropValue(value.into()))
return Err(ParseChunkError::UnknownPropValue(value.into()))
};
state = state.set(prop_name, prop_value);
@ -159,7 +154,7 @@ where
debug_assert!(converted_block_palette.len() > 1);
let Some(Value::LongArray(data)) = block_states.get("data") else {
return Err(ToValenceError::MissingBlockStateData)
return Err(ParseChunkError::MissingBlockStateData)
};
let bits_per_idx = bit_width(converted_block_palette.len() - 1).max(4);
@ -168,7 +163,7 @@ where
let mask = 2_u64.pow(bits_per_idx as u32) - 1;
if long_count != data.len() {
return Err(ToValenceError::BadBlockLongCount);
return Err(ParseChunkError::BadBlockLongCount);
};
let mut i = 0;
@ -183,7 +178,7 @@ where
let idx = (u64 >> (bits_per_idx * j)) & mask;
let Some(block) = converted_block_palette.get(idx as usize).cloned() else {
return Err(ToValenceError::BadBlockPaletteIndex)
return Err(ParseChunkError::BadBlockPaletteIndex)
};
let x = i % 16;
@ -198,22 +193,22 @@ where
}
let Some(Value::Compound(biomes)) = section.get("biomes") else {
return Err(ToValenceError::MissingBiomes)
return Err(ParseChunkError::MissingBiomes)
};
let Some(Value::List(List::String(palette))) = biomes.get("palette") else {
return Err(ToValenceError::MissingBiomePalette)
return Err(ParseChunkError::MissingBiomePalette)
};
if !(1..BIOMES_PER_SECTION).contains(&palette.len()) {
return Err(ToValenceError::BadBiomePaletteLen);
return Err(ParseChunkError::BadBiomePaletteLen);
}
converted_biome_palette.clear();
for biome_name in palette {
let Ok(ident) = Ident::<Cow<str>>::new(biome_name) else {
return Err(ToValenceError::BadBiomeName)
return Err(ParseChunkError::BadBiomeName)
};
converted_biome_palette.push(map_biome(ident.as_str_ident()));
@ -225,7 +220,7 @@ where
debug_assert!(converted_biome_palette.len() > 1);
let Some(Value::LongArray(data)) = biomes.get("data") else {
return Err(ToValenceError::MissingBiomeData)
return Err(ParseChunkError::MissingBiomeData)
};
let bits_per_idx = bit_width(converted_biome_palette.len() - 1);
@ -234,7 +229,7 @@ where
let mask = 2_u64.pow(bits_per_idx as u32) - 1;
if long_count != data.len() {
return Err(ToValenceError::BadBiomeLongCount);
return Err(ParseChunkError::BadBiomeLongCount);
};
let mut i = 0;
@ -249,7 +244,7 @@ where
let idx = (u64 >> (bits_per_idx * j)) & mask;
let Some(biome) = converted_biome_palette.get(idx as usize).cloned() else {
return Err(ToValenceError::BadBiomePaletteIndex)
return Err(ParseChunkError::BadBiomePaletteIndex)
};
let x = i % 4;
@ -265,41 +260,41 @@ where
}
let Some(Value::List(block_entities)) = nbt.get("block_entities") else {
return Err(ToValenceError::MissingBlockEntity);
return Err(ParseChunkError::MissingBlockEntity);
};
if let List::Compound(block_entities) = block_entities {
for comp in block_entities {
let Some(Value::String(ident)) = comp.get("id") else {
return Err(ToValenceError::MissingBlockEntityIdent);
return Err(ParseChunkError::MissingBlockEntityIdent);
};
let Ok(ident) = Ident::new(&ident[..]) else {
return Err(ToValenceError::UnknownBlockEntityIdent(ident.clone()));
return Err(ParseChunkError::UnknownBlockEntityIdent(ident.clone()));
};
let Some(kind) = BlockEntityKind::from_ident(ident.as_str_ident()) else {
return Err(ToValenceError::UnknownBlockEntityIdent(ident.as_str().to_string()));
return Err(ParseChunkError::UnknownBlockEntityIdent(ident.as_str().to_string()));
};
let block_entity = BlockEntity {
kind,
nbt: comp.clone(),
};
let Some(Value::Int(x)) = comp.get("x") else {
return Err(ToValenceError::InvalidBlockEntityPosition);
return Err(ParseChunkError::InvalidBlockEntityPosition);
};
let Ok(x) = usize::try_from(x.mod_floor(&16)) else {
return Err(ToValenceError::InvalidBlockEntityPosition);
return Err(ParseChunkError::InvalidBlockEntityPosition);
};
let Some(Value::Int(y)) = comp.get("y") else {
return Err(ToValenceError::InvalidBlockEntityPosition);
return Err(ParseChunkError::InvalidBlockEntityPosition);
};
let Ok(y) = usize::try_from(y + sect_offset * 16) else {
return Err(ToValenceError::InvalidBlockEntityPosition);
return Err(ParseChunkError::InvalidBlockEntityPosition);
};
let Some(Value::Int(z)) = comp.get("z") else {
return Err(ToValenceError::InvalidBlockEntityPosition);
return Err(ParseChunkError::InvalidBlockEntityPosition);
};
let Ok(z) = usize::try_from(z.mod_floor(&16)) else {
return Err(ToValenceError::InvalidBlockEntityPosition);
return Err(ParseChunkError::InvalidBlockEntityPosition);
};
chunk.set_block_entity(x, y, z, block_entity);

View file

@ -72,7 +72,6 @@ use valence_registry::tags::TagsRegistry;
use valence_registry::RegistrySet;
pub mod action;
pub mod chat;
pub mod command;
pub mod custom_payload;
pub mod event_loop;
@ -81,6 +80,7 @@ pub mod interact_block;
pub mod interact_entity;
pub mod interact_item;
pub mod keepalive;
pub mod message;
pub mod movement;
pub mod op_level;
pub mod packet;
@ -107,8 +107,10 @@ pub struct FlushPacketsSet;
pub struct SpawnClientsSet;
/// The system set where various facets of the client are updated. Systems that
/// modify chunks should run _before_ this.
#[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
struct UpdateClientsSet;
pub struct UpdateClientsSet;
impl Plugin for ClientPlugin {
fn build(&self, app: &mut App) {
@ -150,7 +152,7 @@ impl Plugin for ClientPlugin {
action::build(app);
teleport::build(app);
weather::build(app);
chat::build(app);
message::build(app);
custom_payload::build(app);
hand_swing::build(app);
interact_block::build(app);

View file

@ -7,7 +7,6 @@ use valence_core::protocol::packet::chat::{ChatMessageC2s, GameMessageS2c};
use valence_core::text::Text;
use crate::event_loop::{EventLoopSchedule, EventLoopSet, PacketEvent};
use crate::Client;
pub(super) fn build(app: &mut App) {
app.add_event::<ChatMessageEvent>().add_system(
@ -17,22 +16,34 @@ pub(super) fn build(app: &mut App) {
);
}
#[derive(Clone, Debug)]
pub struct ChatMessageEvent {
pub client: Entity,
pub message: Box<str>,
pub timestamp: u64,
pub trait SendMessage {
/// Sends a system message visible in the chat.
fn send_chat_message(&mut self, msg: impl Into<Text>);
/// Displays a message in the player's action bar (text above the hotbar).
fn send_action_bar_message(&mut self, msg: impl Into<Text>);
}
impl Client {
/// Sends a system message to the player which is visible in the chat. The
/// message is only visible to this client.
pub fn send_message(&mut self, msg: impl Into<Text>) {
impl<T: WritePacket> SendMessage for T {
fn send_chat_message(&mut self, msg: impl Into<Text>) {
self.write_packet(&GameMessageS2c {
chat: msg.into().into(),
overlay: false,
});
}
fn send_action_bar_message(&mut self, msg: impl Into<Text>) {
self.write_packet(&GameMessageS2c {
chat: msg.into().into(),
overlay: true,
});
}
}
#[derive(Clone, Debug)]
pub struct ChatMessageEvent {
pub client: Entity,
pub message: Box<str>,
pub timestamp: u64,
}
pub fn handle_chat_message(

View file

@ -64,7 +64,8 @@ mod paletted_container;
pub struct InstancePlugin;
/// When Minecraft entity changes are written to the packet buffers of chunks.
/// Systems that read from the packet buffer of chunks should run _after_ this.
/// Systems that modify entites should run _before_ this. Systems that read from
/// the packet buffer of chunks should run _after_ this.
#[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct WriteUpdatePacketsToInstancesSet;

View file

@ -45,7 +45,8 @@
//! ## Access other world border properties.
//! Access to the rest of the world border properties is fairly straightforward
//! by querying their respective component. [`WorldBorderBundle`] contains
//! references for all properties of the world border and their respective component
//! references for all properties of the world border and their respective
//! component
#![allow(clippy::type_complexity)]
#![deny(
rustdoc::broken_intra_doc_links,