Player list rework (#317)

## Description

- Redesigned the player list so that every entry is an entity in the
ECS. Entry components overlap with client components where possible.
- Clients are now automatically added and removed from the player list
unless configured not to.
- Updated player list example.

## Test Plan

Steps:
1. Run any of the examples.
2. Run player list example.
This commit is contained in:
Ryan Johnson 2023-04-10 01:40:03 -07:00 committed by GitHub
parent a68792e605
commit d78627e478
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 368 additions and 745 deletions

View file

@ -27,7 +27,6 @@ fn main() {
init_clients,
despawn_disconnected_clients,
))
.add_systems(PlayerList::default_systems())
.run();
}

View file

@ -11,7 +11,6 @@ pub fn main() {
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_systems((init_clients, despawn_disconnected_clients))
.add_systems(PlayerList::default_systems())
.run();
}

View file

@ -16,7 +16,6 @@ pub fn main() {
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_systems((event_handler, init_clients, despawn_disconnected_clients))
.add_systems(PlayerList::default_systems())
.run();
}

View file

@ -21,7 +21,6 @@ pub fn main() {
digging_survival_mode,
place_blocks,
))
.add_systems(PlayerList::default_systems())
.run();
}

View file

@ -14,7 +14,6 @@ pub fn main() {
.add_startup_system(setup)
.add_system(init_clients)
.add_systems((toggle_gamemode_on_sneak, open_chest))
.add_systems(PlayerList::default_systems())
.add_system(despawn_disconnected_clients)
.run();
}

View file

@ -24,7 +24,6 @@ pub fn main() {
.add_startup_system(setup)
.add_system(init_clients)
.add_system(handle_combat_events.in_schedule(EventLoopSchedule))
.add_systems(PlayerList::default_systems())
.add_system(despawn_disconnected_clients)
.add_system(teleport_oob_clients)
.run();

View file

@ -27,7 +27,6 @@ pub fn main() {
.add_startup_system(setup_biomes.before(setup))
.add_startup_system(setup)
.add_system(init_clients)
.add_systems(PlayerList::default_systems())
.add_systems((
despawn_disconnected_clients,
toggle_cell_on_dig,

View file

@ -26,7 +26,6 @@ fn main() {
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_system(init_clients)
.add_systems(PlayerList::default_systems())
.add_system(update_sphere)
.add_system(despawn_disconnected_clients)
.run();

View file

@ -12,7 +12,6 @@ pub fn main() {
.add_plugin(ServerPlugin::new(()).with_connection_mode(ConnectionMode::Offline))
.add_startup_system(setup)
.add_systems((init_clients, squat_and_die, necromancy))
.add_systems(PlayerList::default_systems())
.add_system(despawn_disconnected_clients)
.run();
}

View file

@ -29,7 +29,6 @@ pub fn main() {
App::new()
.add_plugin(ServerPlugin::new(()))
.add_system(init_clients)
.add_systems(PlayerList::default_systems())
.add_systems((
reset_clients.after(init_clients),
manage_chunks.after(reset_clients).before(manage_blocks),

View file

@ -13,7 +13,6 @@ pub fn main() {
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_system(init_clients)
.add_systems(PlayerList::default_systems())
.add_system(despawn_disconnected_clients)
.add_system(manage_particles)
.run();

View file

@ -1,7 +1,7 @@
#![allow(clippy::type_complexity)]
use rand::Rng;
use valence::player_list::Entry;
use valence::player_list::{DisplayName, PlayerListEntryBundle};
use valence::prelude::*;
const SPAWN_Y: i32 = 64;
@ -14,11 +14,10 @@ fn main() {
App::new()
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_systems(PlayerList::default_systems())
.add_systems((
init_clients,
override_display_name,
update_player_list,
remove_disconnected_clients_from_player_list,
despawn_disconnected_clients,
))
.run();
@ -26,7 +25,6 @@ fn main() {
fn setup(
mut commands: Commands,
mut player_list: ResMut<PlayerList>,
server: Res<Server>,
dimensions: Query<&DimensionType>,
biomes: Query<&Biome>,
@ -47,29 +45,18 @@ fn setup(
commands.spawn(instance);
player_list.insert(
PLAYER_UUID_1,
PlayerListEntry::new().with_display_name(Some("persistent entry with no ping")),
);
commands.spawn(PlayerListEntryBundle {
uuid: UniqueId(PLAYER_UUID_1),
display_name: DisplayName(Some("persistent entry with no ping".into())),
..Default::default()
});
}
fn init_clients(
mut clients: Query<
(
&mut Client,
&mut Position,
&mut Location,
&mut GameMode,
&Username,
&Properties,
&UniqueId,
),
Added<Client>,
>,
mut clients: Query<(&mut Client, &mut Position, &mut Location, &mut GameMode), Added<Client>>,
instances: Query<Entity, With<Instance>>,
mut player_list: ResMut<PlayerList>,
) {
for (mut client, mut pos, mut loc, mut game_mode, username, props, uuid) in &mut clients {
for (mut client, mut pos, mut loc, mut game_mode) in &mut clients {
pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]);
loc.0 = instances.single();
*game_mode = GameMode::Creative;
@ -79,19 +66,21 @@ fn init_clients(
.italic()
.color(Color::WHITE),
);
let entry = PlayerListEntry::new()
.with_username(&username.0)
.with_properties(props.0.clone()) // For the player's skin and cape.
.with_game_mode(*game_mode)
.with_ping(0) // Use negative values to indicate missing.
.with_display_name(Some("".color(Color::new(255, 87, 66))));
player_list.insert(uuid.0, entry);
}
}
fn update_player_list(mut player_list: ResMut<PlayerList>, server: Res<Server>) {
fn override_display_name(mut clients: Query<&mut DisplayName, (Added<DisplayName>, With<Client>)>) {
for mut display_name in &mut clients {
display_name.0 = Some("".color(Color::new(255, 87, 66)));
}
}
fn update_player_list(
mut player_list: ResMut<PlayerList>,
server: Res<Server>,
mut entries: Query<(Entity, &UniqueId, &mut DisplayName), With<PlayerListEntry>>,
mut commands: Commands,
) {
let tick = server.current_tick();
player_list.set_header("Current tick: ".into_text() + tick);
@ -99,38 +88,27 @@ fn update_player_list(mut player_list: ResMut<PlayerList>, server: Res<Server>)
.set_footer("Current tick but in purple: ".into_text() + tick.color(Color::LIGHT_PURPLE));
if tick % 5 == 0 {
for (_, uuid, mut display_name) in &mut entries {
if uuid.0 == PLAYER_UUID_1 {
let mut rng = rand::thread_rng();
let color = Color::new(rng.gen(), rng.gen(), rng.gen());
let entry = player_list.get_mut(PLAYER_UUID_1).unwrap();
let new_display_name = entry.display_name().unwrap().clone().color(color);
entry.set_display_name(Some(new_display_name));
}
if tick % 20 == 0 {
match player_list.entry(PLAYER_UUID_2) {
Entry::Occupied(oe) => {
oe.remove();
}
Entry::Vacant(ve) => {
let entry = PlayerListEntry::new()
.with_display_name(Some("Hello!"))
.with_ping(300);
ve.insert(entry);
}
let new_name = display_name.0.clone().unwrap_or_default().color(color);
display_name.0 = Some(new_name);
}
}
}
fn remove_disconnected_clients_from_player_list(
mut clients: RemovedComponents<Client>,
mut player_list: ResMut<PlayerList>,
uuids: Query<&UniqueId>,
) {
for client in clients.iter() {
if let Ok(UniqueId(uuid)) = uuids.get(client) {
player_list.remove(*uuid);
if tick % 20 == 0 {
if let Some((entity, _, _)) = entries.iter().find(|(_, uuid, _)| uuid.0 == PLAYER_UUID_2) {
commands.entity(entity).insert(Despawned);
} else {
commands.spawn(PlayerListEntryBundle {
uuid: UniqueId(PLAYER_UUID_2),
display_name: DisplayName(Some("Hello!".into())),
ping: Ping(300),
..Default::default()
});
}
}
}

View file

@ -15,7 +15,6 @@ pub fn main() {
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_systems((init_clients, prompt_on_punch, on_resource_pack_status))
.add_systems(PlayerList::default_systems())
.add_system(despawn_disconnected_clients)
.run();
}

View file

@ -54,7 +54,6 @@ pub fn main() {
.chain(),
)
.add_system(despawn_disconnected_clients)
.add_systems(PlayerList::default_systems())
.run();
}

View file

@ -12,7 +12,6 @@ pub fn main() {
.add_plugin(ServerPlugin::new(()))
.add_startup_system(setup)
.add_system(init_clients)
.add_systems(PlayerList::default_systems())
.add_system(despawn_disconnected_clients)
.run();
}

View file

@ -114,7 +114,7 @@ pub(crate) struct ClientBundle {
client: Client,
settings: settings::ClientSettings,
scratch: ScratchBuf,
entity_remove_buffer: EntityRemoveBuf,
entity_remove_buf: EntityRemoveBuf,
username: Username,
ip: Ip,
properties: Properties,
@ -151,7 +151,7 @@ impl ClientBundle {
client: Client { conn, enc },
settings: settings::ClientSettings::default(),
scratch: ScratchBuf::default(),
entity_remove_buffer: EntityRemoveBuf(vec![]),
entity_remove_buf: EntityRemoveBuf(vec![]),
username: Username(info.username),
ip: Ip(info.ip),
properties: Properties(info.properties),

View file

@ -40,6 +40,7 @@ fn send_keepalive(
) {
if server.current_tick() % (server.tps() * 10) == 0 {
let mut rng = rand::thread_rng();
let now = Instant::now();
for (entity, mut client, mut state) in &mut clients {
if state.got_keepalive {
@ -48,8 +49,7 @@ fn send_keepalive(
state.got_keepalive = false;
state.last_keepalive_id = id;
// TODO: start timing when the packets are flushed.
state.keepalive_sent_time = Instant::now();
state.keepalive_sent_time = now;
} else {
warn!("Client {entity:?} timed out (no keepalive response)");
commands.entity(entity).remove::<Client>();

View file

@ -11,6 +11,39 @@ use crate::client::FlushPacketsSet;
use crate::util::{from_yaw_and_pitch, to_yaw_and_pitch};
use crate::view::ChunkPos;
pub(crate) struct ComponentPlugin;
impl Plugin for ComponentPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_systems(
(update_old_position, update_old_location)
.in_base_set(CoreSet::PostUpdate)
.after(FlushPacketsSet),
)
.add_system(despawn_marked_entities.in_base_set(CoreSet::Last));
}
}
fn update_old_position(mut query: Query<(&Position, &mut OldPosition)>) {
for (pos, mut old_pos) in &mut query {
old_pos.0 = pos.0;
}
}
fn update_old_location(mut query: Query<(&Location, &mut OldLocation)>) {
for (loc, mut old_loc) in &mut query {
old_loc.0 = loc.0;
}
}
/// Despawns all the entities marked as despawned with the [`Despawned`]
/// component.
fn despawn_marked_entities(mut commands: Commands, entities: Query<Entity, With<Despawned>>) {
for entity in &entities {
commands.entity(entity).despawn();
}
}
/// A [`Component`] for marking entities that should be despawned at the end of
/// the tick.
///
@ -41,7 +74,7 @@ impl Default for UniqueId {
}
}
#[derive(Component, Clone, PartialEq, Eq, Debug)]
#[derive(Component, Clone, PartialEq, Eq, Default, Debug)]
pub struct Username(pub String);
impl fmt::Display for Username {
@ -235,40 +268,9 @@ impl Look {
#[derive(Component, Copy, Clone, PartialEq, Eq, Default, Debug)]
pub struct OnGround(pub bool);
/// General-purpose reusable byte buffer.
///
/// No guarantees are made about the buffer's contents between systems.
/// Therefore, the inner `Vec` should be cleared before use.
#[derive(Component, Default, Debug)]
pub struct ScratchBuf(pub Vec<u8>);
pub(crate) struct ComponentPlugin;
impl Plugin for ComponentPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_systems(
(update_old_position, update_old_location)
.in_base_set(CoreSet::PostUpdate)
.after(FlushPacketsSet),
)
// This is fine because we're applying system buffers later.
.add_system(despawn_marked_entities.in_base_set(CoreSet::PostUpdate
));
}
}
fn update_old_position(mut query: Query<(&Position, &mut OldPosition), Changed<Position>>) {
for (pos, mut old_pos) in &mut query {
old_pos.0 = pos.0;
}
}
fn update_old_location(mut query: Query<(&Location, &mut OldLocation), Changed<Location>>) {
for (loc, mut old_loc) in &mut query {
old_loc.0 = loc.0;
}
}
/// Despawns all the entities marked as despawned with the [`Despawned`]
/// component.
fn despawn_marked_entities(mut commands: Commands, entities: Query<Entity, With<Despawned>>) {
for entity in &entities {
commands.entity(entity).despawn();
}
}

View file

@ -1,169 +1,59 @@
use std::borrow::Cow;
use std::collections::hash_map::{Entry as MapEntry, OccupiedEntry as OccupiedMapEntry};
use std::collections::HashMap;
use std::iter::FusedIterator;
use std::mem;
use bevy_app::{CoreSet, Plugin};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::SystemConfigs;
use tracing::warn;
use uuid::Uuid;
use valence_protocol::packet::s2c::play::player_list::{
Actions, Entry as PlayerInfoEntry, PlayerListS2c,
};
use valence_protocol::packet::s2c::play::player_list::{Actions, Entry, PlayerListS2c};
use valence_protocol::packet::s2c::play::{PlayerListHeaderS2c, PlayerRemoveS2c};
use valence_protocol::text::Text;
use valence_protocol::types::Property;
use crate::client::{Client, FlushPacketsSet};
use crate::component::{GameMode, Ping, Properties, UniqueId, Username};
use crate::component::{Despawned, GameMode, Ping, Properties, UniqueId, Username};
use crate::packet::{PacketWriter, WritePacket};
use crate::server::Server;
/// The global list of players on a server visible by pressing the tab key by
/// default.
///
/// Each entry in the player list is intended to represent a connected client to
/// the server. In addition to a list of players, the player list has a header
/// and a footer which can contain arbitrary text.
///
/// ```ignore
/// # use uuid::Uuid;
/// # use valence::player_list::{PlayerList, PlayerListEntry};
///
/// # let mut player_list = PlayerList::new();
/// player_list.set_header("Hello, world!");
/// player_list.set_footer("Goodbye, world!");
/// player_list.insert(
/// Uuid::new_v4(),
/// PlayerListEntry::new()
/// .with_username("Notch")
/// .with_display_name(Some("Herobrine")),
/// );
/// ```
#[derive(Debug, Resource)]
pub(crate) struct PlayerListPlugin;
impl Plugin for PlayerListPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(PlayerList::new()).add_systems(
(
update_header_footer,
add_new_clients_to_player_list,
apply_system_buffers, // So new clients get the packets for their own entry.
update_entries,
init_player_list_for_clients,
remove_despawned_entries,
write_player_list_changes,
)
.chain()
.before(FlushPacketsSet)
.in_base_set(CoreSet::PostUpdate),
);
}
}
#[derive(Resource)]
pub struct PlayerList {
cached_update_packets: Vec<u8>,
entries: HashMap<Uuid, Option<PlayerListEntry>>,
scratch: Vec<u8>,
header: Text,
footer: Text,
modified_header_or_footer: bool,
changed_header_or_footer: bool,
/// If clients should be automatically added and removed from the player
/// list with the proper components inserted. Enabled by default.
pub manage_clients: bool,
}
impl PlayerList {
/// Returns a set of systems for maintaining the player list in a reasonable
/// default way. When clients connect, they are added to the player list.
/// When clients disconnect, they are removed from the player list.
pub fn default_systems() -> SystemConfigs {
fn add_new_clients_to_player_list(
clients: Query<(&Username, &Properties, &GameMode, &Ping, &UniqueId), Added<Client>>,
mut player_list: ResMut<PlayerList>,
) {
for (username, properties, game_mode, ping, uuid) in &clients {
let entry = PlayerListEntry::new()
.with_username(&username.0)
.with_properties(properties.0.clone())
.with_game_mode(*game_mode)
.with_ping(ping.0);
player_list.insert(uuid.0, entry);
}
}
fn remove_disconnected_clients_from_player_list(
mut clients: RemovedComponents<Client>,
uuids: Query<&UniqueId>,
mut player_list: ResMut<PlayerList>,
) {
for entity in clients.iter() {
if let Ok(UniqueId(uuid)) = uuids.get(entity) {
player_list.remove(*uuid);
}
}
}
(
add_new_clients_to_player_list,
remove_disconnected_clients_from_player_list,
)
.into_configs()
}
}
impl PlayerList {
/// Create a new empty player list.
fn new() -> Self {
Self {
cached_update_packets: vec![],
entries: HashMap::new(),
scratch: vec![],
header: Text::default(),
footer: Text::default(),
modified_header_or_footer: false,
}
}
/// Get the entry for the given UUID, if it exists, otherwise return None.
pub fn get(&self, uuid: Uuid) -> Option<&PlayerListEntry> {
self.entries.get(&uuid).and_then(|opt| opt.as_ref())
}
/// Mutably get the entry for the given UUID, if it exists, otherwise return
/// None.
pub fn get_mut(&mut self, uuid: Uuid) -> Option<&mut PlayerListEntry> {
self.entries.get_mut(&uuid).and_then(|opt| opt.as_mut())
}
/// Get an iterator over all entries in the player list. The order of this
/// iterator is not guaranteed.
pub fn iter(&self) -> impl FusedIterator<Item = (Uuid, &PlayerListEntry)> + Clone + '_ {
self.entries
.iter()
.filter_map(|(&uuid, opt)| opt.as_ref().map(|entry| (uuid, entry)))
}
/// Get an iterator over all entries in the player list as mutable. The
/// order of this iterator is not guaranteed.
pub fn iter_mut(&mut self) -> impl FusedIterator<Item = (Uuid, &mut PlayerListEntry)> + '_ {
self.entries
.iter_mut()
.filter_map(|(&uuid, opt)| opt.as_mut().map(|entry| (uuid, entry)))
}
/// Insert a new entry into the player list. If an entry already exists for
/// the given UUID, it is replaced and returned.
pub fn insert(&mut self, uuid: Uuid, entry: PlayerListEntry) -> Option<PlayerListEntry> {
match self.entry(uuid) {
Entry::Occupied(mut oe) => Some(oe.insert(entry)),
Entry::Vacant(ve) => {
ve.insert(entry);
None
}
}
}
/// Remove an entry from the player list. If an entry exists for the given
/// UUID, it is removed and returned.
pub fn remove(&mut self, uuid: Uuid) -> Option<PlayerListEntry> {
match self.entry(uuid) {
Entry::Occupied(oe) => Some(oe.remove()),
Entry::Vacant(_) => None,
}
}
/// Gets the given keys corresponding entry in the map for in-place
/// manipulation.
pub fn entry(&mut self, uuid: Uuid) -> Entry {
match self.entries.entry(uuid) {
MapEntry::Occupied(oe) if oe.get().is_some() => {
Entry::Occupied(OccupiedEntry { entry: oe })
}
MapEntry::Occupied(oe) => Entry::Vacant(VacantEntry {
entry: MapEntry::Occupied(oe),
}),
MapEntry::Vacant(ve) => Entry::Vacant(VacantEntry {
entry: MapEntry::Vacant(ve),
}),
changed_header_or_footer: false,
manage_clients: true,
}
}
@ -171,58 +61,121 @@ impl PlayerList {
&self.header
}
/// Set the header text for the player list. Returns the previous header.
pub fn set_header(&mut self, header: impl Into<Text>) -> Text {
let header = header.into();
if header != self.header {
self.modified_header_or_footer = true;
}
mem::replace(&mut self.header, header)
}
pub fn footer(&self) -> &Text {
&self.footer
}
/// Set the footer text for the player list. Returns the previous footer.
pub fn set_footer(&mut self, footer: impl Into<Text>) -> Text {
let footer = footer.into();
pub fn set_header(&mut self, txt: impl Into<Text>) {
let txt = txt.into();
if footer != self.footer {
self.modified_header_or_footer = true;
if txt != self.header {
self.changed_header_or_footer = true;
}
mem::replace(&mut self.footer, footer)
self.header = txt;
}
/// Retains only the elements specified by the predicate.
pub fn set_footer(&mut self, txt: impl Into<Text>) {
let txt = txt.into();
if txt != self.footer {
self.changed_header_or_footer = true;
}
self.footer = txt;
}
}
/// Bundle for spawning new player list entries. All components are required
/// unless otherwise stated.
///
/// In other words, remove all pairs `(k, v)` for which `f(&k, &mut v)`
/// returns `false`. The elements are visited in unsorted (and
/// unspecified) order.
pub fn retain<F>(&mut self, mut f: F)
where
F: FnMut(Uuid, &mut PlayerListEntry) -> bool,
{
self.entries.retain(|&uuid, opt| {
if let Some(entry) = opt {
if !f(uuid, entry) {
*opt = None;
/// # Despawning player list entries
///
/// The [`Despawned`] component must be used to despawn player list entries.
#[derive(Bundle, Default, Debug)]
pub struct PlayerListEntryBundle {
pub player_list_entry: PlayerListEntry,
/// Careful not to modify this!
pub uuid: UniqueId,
pub username: Username,
pub properties: Properties,
pub game_mode: GameMode,
pub ping: Ping,
pub display_name: DisplayName,
pub listed: Listed,
}
/// Marker component for player list entries.
#[derive(Component, Default, Debug)]
pub struct PlayerListEntry;
/// Displayed name for a player list entry. Appears as [`Username`] if `None`.
#[derive(Component, Default, Debug)]
pub struct DisplayName(pub Option<Text>);
/// If a player list entry is visible. Defaults to `true`.
#[derive(Component, Copy, Clone, Debug)]
pub struct Listed(pub bool);
impl Default for Listed {
fn default() -> Self {
Self(true)
}
}
true
fn update_header_footer(player_list: ResMut<PlayerList>, server: Res<Server>) {
if player_list.changed_header_or_footer {
let player_list = player_list.into_inner();
let mut w = PacketWriter::new(
&mut player_list.cached_update_packets,
server.compression_threshold(),
&mut player_list.scratch,
);
w.write_packet(&PlayerListHeaderS2c {
header: (&player_list.header).into(),
footer: (&player_list.footer).into(),
});
player_list.changed_header_or_footer = false;
}
}
/// Clear the player list.
pub fn clear(&mut self) {
self.entries.values_mut().for_each(|e| *e = None);
fn add_new_clients_to_player_list(
clients: Query<Entity, Added<Client>>,
player_list: Res<PlayerList>,
mut commands: Commands,
) {
if player_list.manage_clients {
for entity in &clients {
commands.entity(entity).insert((
PlayerListEntry,
DisplayName::default(),
Listed::default(),
));
}
}
}
pub(crate) fn write_init_packets(&self, mut writer: impl WritePacket) {
fn init_player_list_for_clients(
mut clients: Query<&mut Client, (Added<Client>, Without<Despawned>)>,
player_list: Res<PlayerList>,
entries: Query<
(
&UniqueId,
&Username,
&Properties,
&GameMode,
&Ping,
&DisplayName,
&Listed,
),
With<PlayerListEntry>,
>,
) {
if player_list.manage_clients {
for mut client in &mut clients {
let actions = Actions::new()
.with_add_player(true)
.with_update_game_mode(true)
@ -230,471 +183,175 @@ impl PlayerList {
.with_update_latency(true)
.with_update_display_name(true);
let entries: Vec<_> = self
.entries
let entries: Vec<_> = entries
.iter()
.filter_map(|(&uuid, opt)| {
opt.as_ref().map(|entry| PlayerInfoEntry {
player_uuid: uuid,
username: &entry.username,
properties: entry.properties().into(),
.map(
|(uuid, username, props, game_mode, ping, display_name, listed)| Entry {
player_uuid: uuid.0,
username: &username.0,
properties: Cow::Borrowed(&props.0),
chat_data: None,
listed: entry.listed,
ping: entry.ping,
game_mode: entry.game_mode.into(),
display_name: entry.display_name.as_ref().map(|t| t.into()),
})
})
listed: listed.0,
ping: ping.0,
game_mode: (*game_mode).into(),
display_name: display_name.0.as_ref().map(Cow::Borrowed),
},
)
.collect();
if !entries.is_empty() {
writer.write_packet(&PlayerListS2c {
client.write_packet(&PlayerListS2c {
actions,
entries: entries.into(),
entries: Cow::Owned(entries),
});
}
if !self.header.is_empty() || !self.footer.is_empty() {
writer.write_packet(&PlayerListHeaderS2c {
header: (&self.header).into(),
footer: (&self.footer).into(),
if !player_list.header.is_empty() || !player_list.footer.is_empty() {
client.write_packet(&PlayerListHeaderS2c {
header: Cow::Borrowed(&player_list.header),
footer: Cow::Borrowed(&player_list.footer),
});
}
}
}
/// Represents a player entry in the [`PlayerList`].
///
/// ```
/// use valence::player_list::PlayerListEntry;
///
/// PlayerListEntry::new()
/// .with_username("Notch")
/// .with_display_name(Some("Herobrine"));
/// ```
#[derive(Clone, Debug)]
pub struct PlayerListEntry {
username: String,
properties: Vec<Property>,
game_mode: GameMode,
old_game_mode: GameMode,
ping: i32,
display_name: Option<Text>,
listed: bool,
old_listed: bool,
is_new: bool,
modified_ping: bool,
modified_display_name: bool,
}
impl Default for PlayerListEntry {
fn default() -> Self {
Self {
username: String::new(),
properties: vec![],
game_mode: GameMode::default(),
old_game_mode: GameMode::default(),
ping: -1, // Negative indicates absence.
display_name: None,
old_listed: true,
listed: true,
is_new: true,
modified_ping: false,
modified_display_name: false,
}
}
}
impl PlayerListEntry {
/// Create a new player list entry.
///
/// ```
/// use valence::player_list::PlayerListEntry;
///
/// PlayerListEntry::new()
/// .with_username("Notch")
/// .with_display_name(Some("Herobrine"));
/// ```
pub fn new() -> Self {
Self::default()
}
/// Set the username for the player list entry. Returns `Self` to chain
/// other options.
#[must_use]
pub fn with_username(mut self, username: impl Into<String>) -> Self {
self.username = username.into();
if self.username.chars().count() > 16 {
warn!("player list username is longer than 16 characters");
}
self
}
/// Set the properties for the player list entry. Returns `Self` to chain
/// other options.
///
/// A property is a key-value pair that can be used to customize the
/// appearance of the player list entry. For example, the skin of the
/// player can be set by adding a property with the key `textures` and
/// the value being a base64 encoded JSON object.
#[must_use]
pub fn with_properties(mut self, properties: impl Into<Vec<Property>>) -> Self {
self.properties = properties.into();
self
}
/// Set the game mode for the player list entry. Returns `Self` to chain
/// other options.
#[must_use]
pub fn with_game_mode(mut self, game_mode: GameMode) -> Self {
self.game_mode = game_mode;
self
}
/// Set the ping for the player list entry. Returns `Self` to chain other
/// options.
///
/// The ping is the number of milliseconds it takes for the server to
/// receive a response from the player. The client will display the
/// ping as a number of green bars, where more bars indicate a lower
/// ping.
///
/// Use a value of `-1` to hide the ping.
#[must_use]
pub fn with_ping(mut self, ping: i32) -> Self {
self.ping = ping;
self
}
/// Set the display name for the player list entry. Returns `Self` to
/// chain other options.
///
/// The display name is the literal text that is displayed in the player
/// list. If this is not set, the username will be used instead.
#[must_use]
pub fn with_display_name(mut self, display_name: Option<impl Into<Text>>) -> Self {
self.display_name = display_name.map(Into::into);
self
}
/// Set whether the player list entry is listed. Returns `Self` to chain
/// other options. Setting this to `false` will hide the entry from the
/// player list.
#[must_use]
pub fn with_listed(mut self, listed: bool) -> Self {
self.listed = listed;
self
}
pub fn username(&self) -> &str {
&self.username
}
pub fn properties(&self) -> &[Property] {
&self.properties
}
pub fn game_mode(&self) -> GameMode {
self.game_mode
}
/// Set the game mode for the player list entry.
pub fn set_game_mode(&mut self, game_mode: GameMode) {
self.game_mode = game_mode;
}
pub fn ping(&self) -> i32 {
self.ping
}
/// Set the ping for the player list entry.
///
/// The ping is the number of milliseconds it takes for the server to
/// receive a response from the player. The client will display the
/// ping as a number of green bars, where more bars indicate a lower
/// ping.
///
/// Use a value of `-1` to hide the ping.
pub fn set_ping(&mut self, ping: i32) {
if self.ping != ping {
self.ping = ping;
self.modified_ping = true;
}
}
pub fn display_name(&self) -> Option<&Text> {
self.display_name.as_ref()
}
/// Set the display name for the player list entry. Returns the previous
/// display name, if any.
///
/// The display name is the literal text that is displayed in the player
/// list. If this is not set, the username will be used instead.
pub fn set_display_name(&mut self, display_name: Option<impl Into<Text>>) -> Option<Text> {
let display_name = display_name.map(Into::into);
if self.display_name != display_name {
self.modified_display_name = true;
}
mem::replace(&mut self.display_name, display_name)
}
pub fn is_listed(&self) -> bool {
self.listed
}
/// Set whether the player list entry is listed. Setting this to `false`
/// will hide the entry from the player list.
pub fn set_listed(&mut self, listed: bool) {
self.listed = listed;
}
fn clear_trackers(&mut self) {
self.old_game_mode = self.game_mode;
self.old_listed = self.listed;
self.modified_ping = false;
self.modified_display_name = false;
}
}
/// An entry in the player list that corresponds to a single UUID. Works like
/// [`std::collections::hash_map::Entry`].
#[derive(Debug)]
pub enum Entry<'a> {
Occupied(OccupiedEntry<'a>),
Vacant(VacantEntry<'a>),
}
#[derive(Debug)]
pub struct OccupiedEntry<'a> {
entry: OccupiedMapEntry<'a, Uuid, Option<PlayerListEntry>>,
}
impl<'a> OccupiedEntry<'a> {
pub fn key(&self) -> &Uuid {
self.entry.key()
}
pub fn remove_entry(mut self) -> (Uuid, PlayerListEntry) {
let mut entry = self.entry.get_mut().take().unwrap();
let uuid = *self.entry.key();
entry.is_new = false;
(uuid, entry)
}
pub fn get(&self) -> &PlayerListEntry {
self.entry.get().as_ref().unwrap()
}
pub fn get_mut(&mut self) -> &mut PlayerListEntry {
self.entry.get_mut().as_mut().unwrap()
}
pub fn into_mut(self) -> &'a mut PlayerListEntry {
self.entry.into_mut().as_mut().unwrap()
}
pub fn insert(&mut self, mut entry: PlayerListEntry) -> PlayerListEntry {
let old_entry = self.get_mut();
// Need to overwrite the entry if the username or properties changed because the
// player list update packet doesn't support modifying these. Otherwise we can
// just modify the existing entry.
if old_entry.username != entry.username || old_entry.properties != entry.properties {
entry.clear_trackers();
entry.is_new = true;
self.entry.insert(Some(entry)).unwrap()
} else {
PlayerListEntry::new()
.with_game_mode(old_entry.game_mode)
.with_ping(old_entry.ping)
.with_display_name(old_entry.set_display_name(entry.display_name))
.with_listed(old_entry.listed)
}
}
pub fn remove(self) -> PlayerListEntry {
self.remove_entry().1
}
}
#[derive(Debug)]
pub struct VacantEntry<'a> {
entry: MapEntry<'a, Uuid, Option<PlayerListEntry>>,
}
impl<'a> VacantEntry<'a> {
pub fn key(&self) -> &Uuid {
self.entry.key()
}
pub fn into_key(self) -> Uuid {
*self.entry.key()
}
pub fn insert(self, mut entry: PlayerListEntry) -> &'a mut PlayerListEntry {
entry.clear_trackers();
entry.is_new = true;
match self.entry {
MapEntry::Occupied(mut oe) => {
oe.insert(Some(entry));
oe.into_mut().as_mut().unwrap()
}
MapEntry::Vacant(ve) => ve.insert(Some(entry)).as_mut().unwrap(),
}
}
}
pub(crate) struct PlayerListPlugin;
impl Plugin for PlayerListPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.insert_resource(PlayerList::new()).add_system(
update_player_list
.before(FlushPacketsSet)
.in_base_set(CoreSet::PostUpdate),
);
}
}
/// Manage all player lists on the server and send updates to clients.
fn update_player_list(
fn remove_despawned_entries(
entries: Query<&UniqueId, (Added<Despawned>, With<PlayerListEntry>)>,
player_list: ResMut<PlayerList>,
server: Res<Server>,
mut clients: Query<&mut Client>,
mut removed: Local<Vec<Uuid>>,
) {
let pl = player_list.into_inner();
if player_list.manage_clients {
debug_assert!(removed.is_empty());
let mut scratch = vec![];
pl.cached_update_packets.clear();
let mut writer = PacketWriter::new(
&mut pl.cached_update_packets,
server.compression_threshold(),
&mut scratch,
);
let mut removed = vec![];
pl.entries.retain(|&uuid, entry| {
let Some(entry) = entry else {
removed.push(uuid);
return false
};
if entry.is_new {
entry.is_new = false;
// Send packets to initialize this entry.
let mut actions = Actions::new().with_add_player(true);
// We don't need to send data for fields if they have the default values.
if entry.listed {
actions.set_update_listed(true);
}
// Negative ping indicates absence.
if entry.ping != 0 {
actions.set_update_latency(true);
}
if entry.game_mode != GameMode::default() {
actions.set_update_game_mode(true);
}
if entry.display_name.is_some() {
actions.set_update_display_name(true);
}
entry.clear_trackers();
let packet_entry = PlayerInfoEntry {
player_uuid: uuid,
username: &entry.username,
properties: Cow::Borrowed(&entry.properties),
chat_data: None,
listed: entry.listed,
ping: entry.ping,
game_mode: entry.game_mode.into(),
display_name: entry.display_name.as_ref().map(|t| t.into()),
};
writer.write_packet(&PlayerListS2c {
actions,
entries: Cow::Borrowed(&[packet_entry]),
});
} else {
let mut actions = Actions::new();
if entry.game_mode != entry.old_game_mode {
entry.old_game_mode = entry.game_mode;
actions.set_update_game_mode(true);
}
if entry.listed != entry.old_listed {
entry.old_listed = entry.listed;
actions.set_update_listed(true);
}
if entry.modified_ping {
entry.modified_ping = false;
actions.set_update_latency(true);
}
if entry.modified_display_name {
entry.modified_display_name = false;
actions.set_update_display_name(true);
}
if u8::from(actions) != 0 {
writer.write_packet(&PlayerListS2c {
actions,
entries: Cow::Borrowed(&[PlayerInfoEntry {
player_uuid: uuid,
username: &entry.username,
properties: Cow::default(),
chat_data: None,
listed: entry.listed,
ping: entry.ping,
game_mode: entry.game_mode.into(),
display_name: entry.display_name.as_ref().map(|t| t.into()),
}]),
});
}
}
true
});
removed.extend(entries.iter().map(|uuid| uuid.0));
if !removed.is_empty() {
writer.write_packet(&PlayerRemoveS2c {
uuids: removed.into(),
let player_list = player_list.into_inner();
let mut w = PacketWriter::new(
&mut player_list.cached_update_packets,
server.compression_threshold(),
&mut player_list.scratch,
);
w.write_packet(&PlayerRemoveS2c {
uuids: Cow::Borrowed(&removed),
});
removed.clear();
}
}
}
if pl.modified_header_or_footer {
pl.modified_header_or_footer = false;
fn update_entries(
entries: Query<
(
Ref<UniqueId>,
Ref<Username>,
Ref<Properties>,
Ref<GameMode>,
Ref<Ping>,
Ref<DisplayName>,
Ref<Listed>,
),
(
With<PlayerListEntry>,
Or<(
Changed<UniqueId>,
Changed<Username>,
Changed<Properties>,
Changed<GameMode>,
Changed<Ping>,
Changed<DisplayName>,
Changed<Listed>,
)>,
),
>,
server: Res<Server>,
player_list: ResMut<PlayerList>,
) {
let player_list = player_list.into_inner();
writer.write_packet(&PlayerListHeaderS2c {
header: (&pl.header).into(),
footer: (&pl.footer).into(),
});
let mut writer = PacketWriter::new(
&mut player_list.cached_update_packets,
server.compression_threshold(),
&mut player_list.scratch,
);
for (uuid, username, props, game_mode, ping, display_name, listed) in &entries {
let mut actions = Actions::new();
// Did a change occur that would force us to overwrite the entry? This also adds
// new entries.
if uuid.is_changed() || username.is_changed() || props.is_changed() {
actions.set_add_player(true);
if *game_mode != GameMode::default() {
actions.set_update_game_mode(true);
}
for mut client in &mut clients {
if client.is_added() {
pl.write_init_packets(client.into_inner());
if ping.0 != 0 {
actions.set_update_latency(true);
}
if display_name.0.is_some() {
actions.set_update_display_name(true);
}
if listed.0 {
actions.set_update_listed(true);
}
} else {
client.write_packet_bytes(&pl.cached_update_packets);
if game_mode.is_changed() {
actions.set_update_game_mode(true);
}
if ping.is_changed() {
actions.set_update_latency(true);
}
if display_name.is_changed() {
actions.set_update_display_name(true);
}
if listed.is_changed() {
actions.set_update_listed(true);
}
debug_assert_ne!(u8::from(actions), 0);
}
let entry = Entry {
player_uuid: uuid.0,
username: &username.0,
properties: (&props.0).into(),
chat_data: None,
listed: listed.0,
ping: ping.0,
game_mode: (*game_mode).into(),
display_name: display_name.0.as_ref().map(|x| x.into()),
};
writer.write_packet(&PlayerListS2c {
actions,
entries: Cow::Borrowed(&[entry]),
});
}
}
fn write_player_list_changes(
mut player_list: ResMut<PlayerList>,
mut clients: Query<&mut Client, Without<Despawned>>,
) {
if !player_list.cached_update_packets.is_empty() {
for mut client in &mut clients {
if !client.is_added() {
client.write_packet_bytes(&player_list.cached_update_packets);
}
}
player_list.cached_update_packets.clear();
}
}

View file

@ -17,6 +17,7 @@ use valence_protocol::types::Property;
use crate::biome::BiomePlugin;
use crate::client::{ClientBundle, ClientPlugin};
use crate::component::ComponentPlugin;
use crate::config::{AsyncCallbacks, ConnectionMode, ServerPlugin};
use crate::dimension::DimensionPlugin;
use crate::entity::EntityPlugin;
@ -24,7 +25,6 @@ use crate::event_loop::{EventLoopPlugin, RunEventLoopSet};
use crate::instance::InstancePlugin;
use crate::inventory::InventoryPlugin;
use crate::player_list::PlayerListPlugin;
use crate::prelude::ComponentPlugin;
use crate::registry_codec::RegistryCodecPlugin;
use crate::server::connect::do_accept_loop;
use crate::weather::WeatherPlugin;