Files
RustNotMC/crates/horizon-client/src/game/remote.rs
T
SashegDev 808fd7924e Клиент: Rust-интерфейс и протокол RSX
Чекпойнт: Rust-меню, настройки, Play, пауза, ресурспаки, кейбинды, RSX-протокол, шрифты и иконки. Перед финальным пушем история будет разбита на логические коммиты.
2026-10-29 22:06:05 +03:00

6526 lines
269 KiB
Rust

//! Multiplayer: the game driven by a server connection instead of the local
//! simulation. Mirrors vanilla's ClientPacketListener (chunks, entities,
//! inventory, health, time, weather, chat, respawn and dimension changes),
//! LocalPlayer.sendPosition (movement packets) and MultiPlayerGameMode
//! (block breaking with sequence numbers, use-item, attacks, container
//! clicks). The player still moves with the local physics, as the vanilla
//! client predicts its own movement; the server stays authoritative for
//! everything else.
use super::remote_chunks::{ChunkDecoder, ChunkFormat, Next};
use super::{Dim, Game, Mining, Screen};
use horizon_client::first_person::{Hand, SwingAnimation};
use horizon_client::{
Result,
chunk::Chunk,
containers::{BlockEntity, Furnace, FurnaceKind},
entities::{Experience, ExperienceOrb, ItemEntity},
hud::{HudBatch, rgba},
items::ItemStack,
menu::Input,
mobs::{Mob, MobType, Who},
motion::{MotionWorld, PlayerMotion},
net::{Event, Session},
save::SaveDir,
sprint::Keys,
streaming::{ChunkGenerator, Proto, Streaming},
tab_list::SPECTATOR,
world::World,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{ITEM_ENTITY_ITEM, Value},
item::{Hashed, Stack},
packets::{
Empty,
play::{self, Clientbound as C, Serverbound as S},
},
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
text::{Language, plain},
};
use horizon_world::{
nbt::Tag,
registry::BlockStateId,
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
};
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
time::{Duration, Instant},
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A text component the server sent, for the HUD.
fn remote_text(tag: &Tag) -> horizon_client::text::Text {
use horizon_client::scoreboard::{comp_text, tag_to_json};
comp_text(&tag_to_json(tag))
}
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
/// component's styles, and the `§` codes a literal holds (`StringDecomposer`).
fn menu_title(tag: &Tag) -> horizon_client::text::Text {
remote_text(tag).resolve_legacy()
}
/// Streaming needs a generator; a server world never generates.
struct NoTerrain;
impl ChunkGenerator for NoTerrain {
type Worker = ();
fn worker(&self) {}
fn proto(&self, _: &mut (), _: i32, _: i32) -> Proto {
Box::new(())
}
fn finish(&self, _: Proto) -> Chunk {
Chunk::empty(0, 0)
}
}
/// A server entity the client tracks (position as last sent).
struct Tracked {
kind: String,
position: [f64; 3],
}
/// Smooth an entity toward a new position over three ticks (InterpolationHandler).
#[derive(Clone, Copy)]
/// `LinearInterpolationHandler`'s target: a position and rotation (yRot,
/// xRot) reached in `steps` ticks.
struct Lerp {
target: [f64; 3],
rotation: Option<(f32, f32)>,
steps: u32,
}
/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a
/// mob: its yaw kept continuous (the same turn as vanilla's, without the
/// wrap that would spin it in the frame between ticks), its pitch clamped.
fn place_interpolated(m: &mut Mob, at: PosRot) {
m.position = Vec3::new(at.position[0], at.position[1], at.position[2]);
m.yaw += wrap_degrees(at.yaw - m.yaw);
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
}
/// What the network thread delivered, in order: an event, or a chunk packet
/// handed to the decoder (with the format it decodes with), whose column
/// comes back from `ChunkDecoder::next`.
enum Received {
Event(Event),
Chunk(Arc<ChunkFormat>),
}
pub(super) struct Remote {
pub session: Session,
pub brand: String,
/// The server announced it runs mods' quiet commands.
pub quiet_commands: bool,
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
joined: bool,
status: String,
pub(super) entity_id: i32,
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
pub(super) dimension: DimensionType,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
/// Options.serverRenderDistance: the server's view distance from the
/// login or the last chunk-cache-radius packet.
pub(super) view_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
was_sprinting: bool,
carried: Option<usize>,
/// The last ClientboundPlayerAbilitiesPacket. Vanilla's LocalPlayer
/// holds its abilities from the login on; here the player is made at
/// the first teleport, which takes them then.
abilities: Option<play::PlayerAbilities>,
loaded_sent: bool,
/// The view distance last sent in ClientInformation.
sent_view: i8,
/// The Chat option's visibility last sent in ClientInformation
/// (`Options.broadcastOptions` sends it again when it changes).
sent_chat_visibility: i32,
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
/// with the sequence of the last action that changed them and the state the server
/// last said they have. Until the server acknowledges that sequence its block updates
/// only replace the remembered state; then the block takes it.
predictions: HashMap<[i32; 3], (i32, i32)>,
/// Open container: (window id, menu name).
pub(super) menu: Option<(i32, String)>,
/// The open menu's title as the server named it, with its styles.
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
use_target: Option<[i32; 3]>,
entities: HashMap<i32, Tracked>,
lerps: HashMap<i32, Lerp>,
/// Living entities' `SteppedInterpolationHandler`s.
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// The happy ghast the player just got off, until the server's teleport
/// puts the player on top of it (`place_ghast_under`).
pub(super) ghast_dismount: Option<u32>,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
health: (f32, i32, f32),
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
/// sends; from then on a drop starts the hurt animation.
flash_on_set_health: bool,
game_mode: i32,
pub chat: Vec<(String, Instant)>,
/// Lines received since joining, for the control socket's events.
pub chat_received: u64,
/// The server's command tree (ClientboundCommandsPacket).
pub commands: Option<horizon_client::command::Dispatcher>,
players: HashMap<u128, String>,
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
/// which of them are listed.
tab_players: HashMap<u128, horizon_client::tab_list::TabPlayer>,
/// Each listed profile's unpacked `textures` property.
skin_textures: HashMap<u128, horizon_client::skins::Textures>,
/// The skin texture each profile's players show, once its download is ready.
skins: HashMap<u128, String>,
listed: std::collections::HashSet<u128>,
/// `ClientboundLoginPacket.onlineMode`: the player list shows heads.
online_mode: bool,
/// Player entities in the level: entity id to profile UUID.
level_players: HashMap<i32, u128>,
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
/// theirs in `Mob::glowing`.
glowing: std::collections::HashSet<i32>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
pub(super) profile_name: String,
destroying: Option<([i32; 3], u8, f32)>,
/// `MultiPlayerGameMode.destroyingItem`: the held item key the break began with.
destroying_item: String,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
/// Keys whose presses send a packet (swap hands, drop, a mount's
/// inventory): like the click queue, they wait for the tick's
/// `handleKeybinds` phase rather than going out between window events.
pub(super) pending_keys: Vec<KeyCode>,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
pub(super) flat: bool,
death_message: Option<horizon_client::text::Text>,
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory (then the off hand) as last synchronised, for creative-mode
/// slot updates.
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
clock: Option<i32>,
/// `ClientClockManager.ClientClockInstance`'s rate and partial tick for
/// the level's clock (its total is `world.day_time`), and
/// `lastTickGameTime`.
clock_rate: f32,
clock_partial: f32,
clock_game_time: i64,
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
chunk_center: (i32, i32),
/// Received events a frame left for the next one, in order, and when
/// the oldest of them first waited (`poll_remote`).
backlog: VecDeque<Received>,
backlog_since: Option<Instant>,
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
/// the received events for the next frame. A batch of 25 to 60 columns
/// (0.1 ms each on an M4 Pro) all in one frame was a 3 to 8 ms frame every
/// time the player crossed a chunk border at 32 chunks; they decode on the
/// chunk decoder's thread now, and the frame only inserts them.
const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
/// Events that have waited this long are all handled in the next frame,
/// whatever inserting their columns costs, up to a column still decoding:
/// the packets after a slow machine's chunks don't wait on its frames.
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
/// The open menu name of a server's horse inventory, then its chest's
/// columns (the packet names no `MenuType`).
const MOUNT_MENU: &str = "horizon:mount";
fn face_direction(face: [i32; 3]) -> u8 {
match face {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => 1,
}
}
/// A protocol number format as the scoreboard's.
fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat {
use horizon_client::scoreboard::{NumberFormat, tag_to_json};
match f {
play::NumberFormat::Blank => NumberFormat::Blank,
play::NumberFormat::Styled(t) => NumberFormat::Styled(tag_to_json(t)),
play::NumberFormat::Fixed(t) => NumberFormat::Fixed(tag_to_json(t)),
}
}
impl Game<'_> {
/// A `Holder<SoundEvent>` off the wire: an inline event's location, or
/// the name of a registered one.
pub(super) fn remote_sound_name(
&self,
sound: &(i32, Option<(String, Option<f32>)>),
) -> Option<String> {
match &sound.1 {
Some((location, _)) => Some(location.clone()),
None => self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:sound_event")?
.name(sound.0)
.map(str::to_owned)
}),
}
}
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
self.connect_server_with_cookies(address, name, None)
}
/// `connect_server` carrying a transfer's stored server cookies
/// (`TransferState`); None on an ordinary join.
pub(super) fn connect_server_with_cookies(
&mut self,
address: &str,
name: &str,
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
if self.defer_world_entry(super::PendingWorldEntry::Server {
address: address.to_owned(),
name: name.to_owned(),
cookies: cookies.clone(),
})? {
return Ok(());
}
self.join_world_rest()?;
if let Err(error) = self.join_server(address, name, None, cookies) {
log::warn!("RustNotMC: could not join server: {error}");
if self.title.is_none() {
self.title = Some(horizon_client::title::Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
));
}
if let Some(title) = &mut self.title {
title.failed(error);
}
self.capture(false);
}
Ok(())
}
/// Join single player's integrated server through memory.
pub(super) fn connect_local(
&mut self,
pipe: horizon_protocol::connection::Pipe,
name: &str,
) -> Result<()> {
self.join_server("local", name, Some(pipe), None)
}
fn join_server(
&mut self,
address: &str,
name: &str,
local: Option<horizon_protocol::connection::Pipe>,
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
self.perf.world_opening();
let root = self.options.resources.clone();
let builtin = Builtin::find(&root)
.and_then(|p| Builtin::load(&p).ok())
.ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?;
let lang = Arc::new(horizon_client::net::load_language(&root));
let view = client_view_distance(self.settings.render_distance);
if local.is_none() {
horizon_client::crash::set_detail(
"Server",
horizon_client::net::shown_address(address),
);
}
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
(
name.to_owned(),
horizon_protocol::crypto::offline_uuid(name),
)
},
|a| (a.name.clone(), a.uuid),
);
self.server_packs_connect(local.is_none().then_some(address), user);
let session = match local {
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
None => {
self.extras_connecting(address, name);
match cookies {
Some(cookies) => Session::connect_transferred(
address,
name,
auth.clone(),
view,
lang.clone(),
cookies,
),
None => Session::connect(address, name, auth.clone(), view, lang.clone()),
}
}
};
let player_type = super::perspective::player_type(&root);
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
self.res.threads(),
);
streaming.remote = true;
streaming.radius = self.settings.render_distance;
self.streaming = streaming;
self.world = World::new(self.res.facts().air, 0);
self.save = Arc::new(SaveDir::new(
std::env::temp_dir().join("horizon-remote-unused"),
));
self.inventory = vec![None; 36];
self.armor = [None, None, None, None];
self.craft_grid = vec![None; 9];
self.craft_size = 2;
self.player = None;
self.spawn = None;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.creatures.projectiles.clear();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
self.orbs.clear();
self.falling.clear();
self.travelling = None;
self.mining = None;
self.container = None;
self.carried = None;
self.using = None;
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
self.potions = Default::default();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
progress: 0.0,
total: 0,
};
// The connected client never opens a server world (single player also
// joins here), so load the registries its argument parsers and local
// suggestions need before receiving the server's command tree.
self.ui.registries = Default::default();
self.build_registries();
self.ui.registries.permission = 0;
self.ui.chat.clear();
self.screen = Screen::Playing;
self.title = None;
self.dim = Dim::Overworld;
self.clear_lod();
self.pending_uploads.clear();
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.apply_settings();
self.clear_waypoints();
self.boss_overlay.reset();
self.remote = Some(Remote {
session,
brand: "unknown".into(),
quiet_commands: false,
builtin: Arc::new(builtin),
lang,
registries: Arc::new(Dynamic::default()),
joined: false,
status: "Connecting to the server...".into(),
entity_id: 0,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
Vec::new(),
7,
)),
decoder: ChunkDecoder::new(),
view_distance: self.settings.render_distance,
sequence: 0,
position_sender: Default::default(),
last_input: 0,
was_sprinting: false,
carried: None,
abilities: None,
loaded_sent: false,
sent_view: view,
sent_chat_visibility: self.settings.chat_visibility as i32,
predictions: HashMap::new(),
menu: None,
menu_title: None,
state_id: 0,
use_target: None,
entities: HashMap::new(),
lerps: HashMap::new(),
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
game_mode: 0,
chat: Vec::new(),
chat_received: 0,
commands: None,
players: HashMap::new(),
tab_players: HashMap::new(),
skin_textures: HashMap::new(),
skins: HashMap::new(),
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
glowing: Default::default(),
profile: 0,
profile_name: String::new(),
destroying: None,
destroying_item: String::new(),
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
stop_mining: false,
placing: None,
flat: false,
death_message: None,
show_death_screen: true,
player_type,
synced: vec![None; 37],
decode_errors: 0,
clock: None,
clock_rate: 1.0,
clock_partial: 0.0,
clock_game_time: 0,
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
self.clock = horizon_client::clock::FixedStep::client();
// The player's advancements and statistics are the server's.
self.adv = Default::default();
self.stats = Default::default();
Ok(())
}
/// Disconnect and return to the multiplayer screen (DisconnectedScreen).
pub(super) fn leave_server(&mut self, reason: Option<String>) -> Result<()> {
if reason.is_none() && self.integrated.as_ref().is_some_and(|s| !s.stopped()) {
return self.begin_closing_save(false);
}
self.finish_perf();
if reason.is_none() {
// Leaving on purpose: nothing for auto-reconnect to retry.
self.extras_left();
}
self.clear_waypoints();
self.boss_overlay.reset();
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.server_packs_disconnect();
self.close_dialogs();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
server.stop()?;
true
}
None => false,
};
horizon_client::crash::clear_detail("Server");
self.clock = Default::default();
self.adv = Default::default();
self.stats = Default::default();
self.debug_charts.reset();
self.tab_list.reset();
self.score.board = Default::default();
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
self.score.board = Default::default();
self.tab_list = Default::default();
self.border = Default::default();
self.screen = Screen::Playing;
self.container = None;
self.clear_lod();
self.pending_uploads.clear();
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
let saves = std::env::current_dir()?.join("saves");
let mut title = horizon_client::title::Title::new(saves.clone(), &self.options.resources);
title.last_server = self.settings.file.string("lastServer");
if single_player && reason.is_none() {
self.title = Some(title);
self.capture(false);
return Ok(());
}
title.mode = horizon_client::title::Mode::Multiplayer(match reason {
Some(reason) => horizon_client::multiplayer::Screen::Disconnected {
reason,
retry: None,
},
None => horizon_client::multiplayer::Screen::list(&saves),
});
self.title = Some(title);
self.capture(false);
Ok(())
}
pub(super) fn send(&self, packet: S) {
if let Some(r) = &self.remote {
r.session.send(packet);
}
}
fn next_sequence(&mut self) -> i32 {
let r = self.remote.as_mut().expect("remote");
r.sequence += 1;
r.sequence
}
fn item_name(&self, id: i32) -> Arc<str> {
let r = self.remote.as_ref().expect("remote");
Arc::from(
r.builtin
.get("minecraft:item")
.and_then(|i| i.name(id))
.unwrap_or("minecraft:air"),
)
}
/// The server's network id of a mob effect (`brewing::EFFECTS` id).
pub(super) fn remote_effect_network_id(&self, effect: &str) -> Option<i32> {
let r = self.remote.as_ref()?;
r.builtin
.get("minecraft:mob_effect")?
.id(&format!("minecraft:{effect}"))
}
/// A mob effect's network id as the effect (`brewing::EFFECTS` id).
pub(super) fn remote_effect_id(&self, effect: i32) -> Option<&'static str> {
let r = self.remote.as_ref()?;
let name = r.builtin.get("minecraft:mob_effect")?.name(effect)?;
horizon_client::brewing::effect(name).map(|d| d.id)
}
/// ClientPacketListener.handleUpdateMobEffect for the player itself
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
/// Other entities' effects are not shown.
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
if self.remote.as_ref().is_none_or(|r| r.entity_id != p.id) {
return;
}
let Some(id) = self.remote_effect_id(p.effect) else {
return;
};
let mut e = horizon_client::effects::EffectInstance::new(id, p.duration, p.amplifier);
e.ambient = p.flags & 1 != 0;
e.visible = p.flags & 2 != 0;
let list = &mut self.potions.effects.list;
match list.iter_mut().find(|x| x.id == id) {
Some(existing) => *existing = e,
None => list.push(e),
}
}
fn item_id(&self, name: &str) -> i32 {
let r = self.remote.as_ref().expect("remote");
r.builtin
.get("minecraft:item")
.and_then(|i| i.id(name))
.unwrap_or(0)
}
/// A stack from the server, decoded as the saved and command item NBT
/// are, so tooltips and models see every field.
pub(super) fn to_stack(&self, s: &Option<Stack>) -> Option<ItemStack> {
let r = self.remote.as_ref()?;
Some(horizon_client::item_sync::from_network(
s.as_ref()?,
&r.builtin,
&r.registries,
))
}
/// A stack to the server (`SetCreativeModeSlot`): the same component
/// patch a Horizon server sends its clients, with the registries' ids, so
/// the server's echo doesn't hand back a bare item.
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
let r = self.remote.as_ref()?;
horizon_client::lan::guest::stack_to_network(
|name| self.item_id(name),
// Built-in first, as a hub's `SyncData::id` does: potions are one.
|registry, name| {
r.builtin
.get(registry)
.and_then(|ids| ids.id(name))
.or_else(|| r.registries.id(registry, name))
},
s,
)
}
/// A line in the connection's chat, with its styles, click and hover
/// events (ChatComponent.addMessage). The lines the Chat option hides
/// are dropped there (its `visibleMessageFilter`).
pub(super) fn chat_line(
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) {
if !self.ui.chat.shows(source) {
return;
}
let text = content.plain();
log::info!("[chat] {text}");
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = (source != horizon_client::chat::ChatSource::Player)
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
self.ui.chat.add(
&self.gui.atlas,
content,
indicator,
source,
self.ticks,
self.screen == Screen::Chat,
);
if self.mods.running() {
self.mods_chat(&text);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
if r.chat.len() > 100 {
r.chat.remove(0);
}
}
}
/// The minecarts the server sent, for the control socket's `status`
/// (their velocity is not tracked here).
pub(super) fn remote_carts(&self) -> Option<Vec<serde_json::Value>> {
let r = self.remote.as_ref()?;
let mut carts: Vec<&Tracked> = r
.entities
.values()
.filter(|t| t.kind.ends_with("minecart"))
.collect();
carts.sort_by(|a, b| {
a.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal)
});
Some(
carts
.into_iter()
.map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]}))
.collect(),
)
}
/// The names in the server's player list, sorted.
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
let mut names: Vec<String> = r.players.values().cloned().collect();
names.sort();
Some(names)
}
/// The open menu's window id (0: the inventory).
pub(super) fn remote_menu_id(&self) -> i32 {
self.remote
.as_ref()
.and_then(|r| r.menu.as_ref())
.map_or(0, |m| m.0)
}
/// The server's last `n` chat lines, newest first.
pub(super) fn remote_chat(&self, n: usize) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
Some(r.chat.iter().rev().take(n).map(|l| l.0.clone()).collect())
}
/// Received events left for the next frame (`poll_remote`): until they
/// are handled, the terrain the server sent is not all there.
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
self.remote.as_mut().unwrap().loaded_sent = true;
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
while let Some(item) = events.pop_front() {
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("RustNotMC: the chunk decoder stopped; a column is missing");
continue;
}
};
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::Reconfigured(registries) => {
self.boss_overlay.reset();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("RustNotMC: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
from_server,
} => {
log::info!("RustNotMC: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
/// ClientSuggestionProvider.customSuggestion: the chat's completion
/// request for an `ask_server` node goes to the server.
fn send_suggestion_request(&mut self) {
if self.remote.is_none() {
return;
}
let Some((id, command)) = self.ui.chat_screen.as_mut().and_then(|s| s.take_request())
else {
return;
};
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
w.string(&command);
self.send(S::Unknown(
horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION,
w.data,
));
}
/// ClientboundCommandSuggestionsPacket: id, range start and length, then
/// each suggestion's text and optional tooltip.
fn handle_command_suggestions(&mut self, data: &[u8]) {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
let mut r = horizon_protocol::buf::Reader::new(data);
let decoded = (|| {
let id = r.varint()?;
let start = r.varint()?;
let length = r.varint()?;
let n = r.count(usize::MAX)?;
let mut list = Vec::with_capacity(n.min(1000));
for _ in 0..n {
let text = r.string(32767)?;
let tooltip = if r.bool()? {
Some(plain(&r.nbt()?, &lang))
} else {
None
};
list.push((text, tooltip));
}
horizon_protocol::Result::Ok((id, start, length, list))
})();
if let Ok((id, start, length, list)) = decoded {
let range = (start.max(0) as usize, length.max(0) as usize);
self.chat_server_suggestions(id, range, list);
}
}
fn set_registries(&mut self, registries: Arc<Dynamic>) {
let biomes: Vec<u16> = registries
.registries
.get("minecraft:worldgen/biome")
.map(|list| {
list.iter()
.map(|(name, _)| self.res.biomes().id(name))
.collect()
})
.unwrap_or_default();
if let Some(r) = self.remote.as_mut() {
let bits = ceil_log2(biomes.len());
r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits));
r.registries = registries;
}
}
/// Switch to a (possibly new) dimension: a fresh client level.
fn enter_remote_dimension(&mut self, spawn: &play::SpawnInfo, keep_world: bool) {
let Some(r) = self.remote.as_mut() else {
return;
};
let dimension = r.registries.dimension(spawn.dimension_type);
let same = r.dimension.name == dimension.name && keep_world;
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
r.loaded_sent = false;
// A replacement LocalPlayer has a fresh sendPosition baseline.
r.position_sender = Default::default();
r.predictions.clear();
self.set_mode_id(spawn.game_type);
self.dim = Dim::from_id(&spawn.dimension);
// ClientPacketListener.handleLogin / handleRespawn: the server's
// Player.getLastDeathLocation, for the recovery compass.
self.level.last_death = spawn
.last_death
.as_ref()
.map(|d| (d.dimension.clone(), d.pos));
if !same {
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
self.res.threads(),
);
streaming.remote = true;
streaming.radius = self.streaming.radius;
self.streaming = streaming;
let (time, day, weather) = (
self.world.game_time,
self.world.day_time,
self.world.weather,
);
let old = std::mem::take(&mut self.world.chunks);
let count = old.len();
horizon_client::chunk::free_columns(old, count);
self.world = World::new(self.res.facts().air, spawn.seed);
self.world.game_time = time;
self.world.day_time = day;
self.world.weather = weather;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
if let Some(r) = self.remote.as_mut() {
r.entities.clear();
r.lerps.clear();
r.stepped.clear();
r.head_lerps.clear();
r.pending_items.clear();
// The entity ids are the old world's; the new dimension's
// player entities re-map to their profiles on AddEntity.
r.level_players.clear();
// A new ClientLevel: a new chunk storage, centred on 0, 0.
r.chunk_center = (0, 0);
}
self.clear_lod();
self.pending_uploads.clear();
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections();
}
}
let has_sky = self
.remote
.as_ref()
.is_none_or(|r| r.dimension.has_skylight);
self.streaming.sky_light = has_sky;
self.streaming.nether_shading = self.dim == Dim::Nether;
// The seed in SpawnInfo is already the hashed one the client's BiomeManager holds.
self.streaming.biome_zoom = horizon_core::biome::BiomeZoom::new(spawn.seed);
self.player = None;
self.apply_settings();
}
/// `ClientChunkCache.Storage.inRange`: within the storage around the
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
self.remote.as_ref().is_none_or(|r| {
let range = r.view_distance.max(2) + 3;
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
})
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
.collect();
if gone.is_empty() {
return;
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in &gone {
freed.extend(self.streaming.forget(&mut self.world, x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
}
horizon_client::chunk::free_columns(freed, gone.len());
// One pass for all of them.
self.world
.block_entities
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
/// changes nothing.
pub(super) fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
let key = (pos[0], pos[1], pos[2]);
let Some(name) = self
.remote
.as_ref()
.and_then(|r| r.builtin.get("minecraft:block_entity_type")?.name(kind))
else {
return;
};
let Some(entity) = self.world.block_entities.get(&key) else {
return;
};
let Tag::Compound(fields) = tag else {
return;
};
if entity.id() != name || fields.is_empty() {
return;
}
let mut full = fields.clone();
full.insert("id".into(), Tag::String(name.into()));
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
full.insert(k.into(), Tag::Int(v));
}
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
self.world.block_entities.insert(key, loaded);
// The section is drawn again with what the block entity shows.
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
}
}
/// `handleAnimate`: a player wakes up, or an entity gets the critical
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
fn remote_animate(&mut self, id: i32, action: u8) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if me {
self.player.as_ref().map(|p| {
let d = p.dimensions;
(
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
m.kind.height,
)
})
};
let Some((feet, width, height)) = body else {
return;
};
let particle = match action {
// Player.stopSleepInBed.
0 => {
if me {
self.sleeping = None;
self.sleep_timer = 100;
}
return;
}
1 => ParticleType::Crit,
2 => ParticleType::EnchantedHit,
_ => return,
};
self.particles
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
}
/// `handleServerData`: the server list entry of the server joined keeps
/// the icon the server sends (`ServerList.saveSingleServer`). Single
/// player has no entry.
fn remote_server_data(&mut self, icon: Option<&[u8]>) {
let (Some(r), Some(png)) = (self.remote.as_ref(), icon) else {
return;
};
if self.integrated.is_some() {
return;
}
if let Ok(dir) = std::env::current_dir() {
horizon_client::multiplayer::save_server_icon(
&dir.join("saves"),
&r.session.address,
png,
);
}
}
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
pub(super) fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
let name = self.res.facts().get(state).name.clone();
match BlockEntity::for_block(&name) {
Some(entity) => {
let same = self
.world
.block_entities
.get(&pos)
.is_some_and(|e| e.id() == entity.id());
if !same {
self.world.block_entities.insert(pos, entity);
}
}
None => {
self.world.block_entities.remove(&pos);
}
}
}
/// ClientLevel.setServerVerifiedBlockState: a block the client predicted keeps its
/// predicted state until the server acknowledges the action; the server's state is
/// remembered for then.
fn server_block(&mut self, pos: [i32; 3], state: i32) {
if let Some((_, known)) = self
.remote
.as_mut()
.and_then(|r| r.predictions.get_mut(&pos))
{
*known = state;
return;
}
self.set_block(pos, state);
}
/// BlockStatePredictionHandler.endPredictionsUpTo (ClientboundBlockChangedAckPacket): the
/// actions up to `sequence` are settled, so the blocks they predicted take the state the
/// server gave them (a break the server refused comes back).
fn end_predictions(&mut self, sequence: i32) {
let Some(r) = self.remote.as_mut() else {
return;
};
let settled: Vec<_> = r
.predictions
.iter()
.filter(|(_, (s, _))| *s <= sequence)
.map(|(&pos, &(_, state))| (pos, state))
.collect();
for (pos, state) in settled {
if let Some(r) = self.remote.as_mut() {
r.predictions.remove(&pos);
}
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 != state {
self.set_block(pos, state);
}
}
}
fn set_block(&mut self, pos: [i32; 3], state: i32) {
let state = BlockStateId(state.max(0) as u32);
if self.world.loaded(pos[0], pos[2])
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
{
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
}
// A block we just placed: play its place sound (the server skips the placer).
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
&& target == pos
&& !self.res.facts().is_air(state)
{
let s = self.res.facts().get(state).sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio
.play(&s.place, Some(at), (s.volume + 1.0) / 2.0, s.pitch * 0.8);
if let Some(r) = self.remote.as_mut() {
r.placing = None;
}
}
}
/// `recreateFromPacket`: yRot and xRot from the packet, the body and head
/// both at its head yaw (LivingEntity).
fn spawn_entity(&mut self, id: i32, kind: &str, position: [f64; 3], rotation: [f32; 3]) {
let [yaw, pitch, head] = rotation;
let at = Vec3::new(position[0], position[1], position[2]);
let mob_type = if kind == "minecraft:player" {
self.remote.as_ref().and_then(|r| r.player_type.clone())
} else {
self.res.mob_types().get(kind).cloned()
};
if kind == "minecraft:ender_dragon" {
self.spawn_remote_dragon(id, position, yaw);
}
if kind == "minecraft:experience_orb" {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
self.orbs.push(ExperienceOrb {
id: id as u32,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
}
if let Some(t) = mob_type {
let mut mob = Mob::new(id as u32, t, at, head);
super::remote_mob_data::client_defaults(&mut mob);
(mob.yaw, mob.previous_yaw) = (yaw, yaw);
(mob.head_pitch, mob.previous_head_pitch) = (pitch, pitch);
mob.persistent = true;
self.mobs.retain(|m| m.id != id as u32);
self.mobs.push(mob);
if let Some(r) = self.remote.as_mut() {
r.stepped.remove(&id);
}
}
if let Some(r) = self.remote.as_mut() {
if kind == "minecraft:item" {
r.pending_items.insert(id, (position, Vec3::ZERO));
}
r.entities.insert(
id,
Tracked {
kind: kind.to_owned(),
position,
},
);
}
}
/// A move packet for entity `id`: to the end of `path` (None: a
/// rotation alone), turned to `rotation` if it has one. `far_snaps`:
/// an `EntityPositionSync`, which puts an entity more than 64 blocks
/// away there at once.
fn move_entity(
&mut self,
id: i32,
path: Option<Path>,
rotation: Option<(f32, f32)>,
on_ground: bool,
far_snaps: bool,
) {
let mob = self.mobs.iter_mut().find(|m| m.id == id as u32);
let tracked = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&id))
.map(|t| t.position);
let position = path
.as_ref()
.map(Path::end)
.or(tracked)
.or_else(|| {
mob.as_ref()
.map(|m| [m.position.x, m.position.y, m.position.z])
})
.unwrap_or([0.0; 3]);
if let Some(r) = self.remote.as_mut() {
if let Some(t) = r.entities.get_mut(&id) {
t.position = position;
}
if let Some(m) = mob {
// A living entity: LivingEntity.createInterpolationHandler's
// SteppedInterpolationHandler, over its type's update interval.
let entity = PosRot {
position: [m.position.x, m.position.y, m.position.z],
yaw: m.yaw,
pitch: m.head_pitch,
};
let far = far_snaps && {
let d = [0, 1, 2].map(|i| position[i] - entity.position[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0
};
let handler = r.stepped.entry(id).or_insert_with(|| {
let interval = horizon_client::lan::tracking::settings(&m.kind.id).1;
SteppedInterpolation::new(interval as i32)
});
let snap = if far {
handler.cancel();
let (yaw, pitch) = rotation.unwrap_or((m.yaw, m.head_pitch));
Some(PosRot {
position,
yaw,
pitch,
})
} else {
match handler.interpolate_to(entity, path, rotation) {
Start::Snap(at) => Some(at),
Start::Interpolating | Start::Unchanged => None,
}
};
if let Some(at) = snap {
place_interpolated(m, at);
m.previous = m.position;
}
m.on_ground = on_ground;
return;
}
// Projectiles keep Entity's NO_OP InterpolationHandler too: the
// server's position replaces theirs at once and their own tick
// flies them on (`tick_remote_projectiles`). Eased towards it
// instead, they only moved when an update came, every 10 to 20
// ticks.
if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
if path.is_some() {
p.position = Vec3::new(position[0], position[1], position[2]);
}
if let Some((yaw, pitch)) = rotation {
p.yaw = yaw;
p.pitch = pitch;
}
return;
}
if path.is_some()
&& (self.potions.move_remote_thrown(
id as u32,
Vec3::new(position[0], position[1], position[2]),
) || self.end.move_remote_eye(id as u32, position))
{
return;
}
// A thrown trident is an arrow: placed at once, flown on by its tick.
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
if path.is_some() {
t.position = Vec3::new(position[0], position[1], position[2]);
}
if let Some((yaw, pitch)) = rotation {
t.yaw = yaw;
t.pitch = pitch;
}
return;
}
// So does a firework rocket: its own tick flies it on.
if path.is_some()
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
{
f.position = Vec3::new(position[0], position[1], position[2]);
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
if path.is_some() {
b.position = Vec3::new(position[0], position[1], position[2]);
}
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
// ItemEntity, FallingBlockEntity and PrimedTnt keep Entity's
// NO_OP InterpolationHandler: moveOrInterpolateTo puts them there
// at once, and their own tick moves them on between the server's
// updates (`tick_remote_entities`, `tick_remote_projectiles`).
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.position = at;
d.on_ground = on_ground;
return;
}
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.position = at;
return;
}
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.position = at;
t.on_ground = on_ground;
return;
}
// AbstractInterpolationHandler.interpolateTo: without a rotation,
// the one it was heading for.
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
r.lerps.insert(
id,
Lerp {
target: position,
rotation,
steps: 3,
},
);
}
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
self.move_remote_text_display(id, position, rotation);
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
}
/// Map a menu slot to Horizon's inventory screen slot ids.
fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
let s = usize::try_from(slot).ok()?;
// A container of `n` slots, then the inventory and the hotbar.
let slots = |n: usize| -> Option<usize> {
match s {
_ if s < n => Some(300 + s),
_ if s < n + 27 => Some(9 + s - n),
_ if s < n + 36 => Some(s - n - 27),
_ => None,
}
};
let rows = |n: usize| slots(n * 9);
match menu {
"inventory" => match s {
0 => Some(200),
1..=4 => Some(100 + s - 1),
5..=8 => Some(500 + s - 5),
9..=35 => Some(s),
36..=44 => Some(s - 36),
// InventoryMenu.SHIELD_SLOT: the off hand.
45 => Some(504),
_ => None,
},
"minecraft:crafting" => match s {
0 => Some(200),
1..=9 => Some(100 + s - 1),
10..=36 => Some(9 + s - 10),
37..=45 => Some(s - 37),
_ => None,
},
"minecraft:merchant" => match s {
0 | 1 => Some(super::stations::INPUT + s),
2 => Some(super::stations::RESULT),
3..=29 => Some(9 + s - 3),
30..=38 => Some(s - 30),
_ => None,
},
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => match s {
0..=2 => Some(300 + s),
3..=29 => Some(9 + s - 3),
30..=38 => Some(s - 30),
_ => None,
},
"minecraft:generic_9x1" => rows(1),
"minecraft:generic_9x2" => rows(2),
"minecraft:generic_9x3" | "minecraft:shulker_box" => rows(3),
"minecraft:generic_9x4" => rows(4),
"minecraft:generic_9x5" => rows(5),
"minecraft:generic_9x6" => rows(6),
// HopperMenu; DispenserMenu (dispensers and droppers).
"minecraft:hopper" => slots(5),
"minecraft:generic_3x3" => slots(9),
"minecraft:brewing_stand" => slots(5),
// CrafterMenu: the grid, the inventory, then the result (the
// client's own).
"minecraft:crafter_3x3" => slots(9),
// HorseInventoryMenu: the saddle, the body armour, the chest,
// then the inventory.
m if m.starts_with(MOUNT_MENU) => {
let n = m[MOUNT_MENU.len()..].parse::<usize>().ok()? * 3;
match s {
0 => Some(super::mount_menu::SADDLE),
1 => Some(super::mount_menu::ARMOR),
_ if s < 2 + n => Some(super::mount_menu::CHEST + s - 2),
_ if s < 2 + n + 27 => Some(9 + s - 2 - n),
_ if s < 2 + n + 36 => Some(s - 2 - n - 27),
_ => None,
}
}
// A workstation: its inputs, its result, then the inventory.
m => {
let kind = Self::remote_station_kind(m)?;
let inputs = kind.inputs();
let own = inputs + usize::from(kind.has_result());
match s {
_ if s < inputs => Some(super::stations::INPUT + s),
_ if s < own => Some(super::stations::RESULT),
_ if s < own + 27 => Some(9 + s - own),
_ if s < own + 36 => Some(s - own - 27),
_ => None,
}
}
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
}
/// The workstation screen of a menu type a server opens.
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
use super::stations::Kind;
Some(match menu {
"minecraft:loom" => Kind::Loom,
"minecraft:stonecutter" => Kind::Stonecutter,
"minecraft:grindstone" => Kind::Grindstone,
"minecraft:anvil" => Kind::Anvil,
"minecraft:smithing" => Kind::Smithing,
"minecraft:enchantment" => Kind::Enchanting,
"minecraft:beacon" => Kind::Beacon,
"minecraft:cartography_table" => Kind::Cartography,
_ => return None,
})
}
fn open_menu_name(&self, container: i32) -> String {
match self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
Some((id, name)) if *id == container && container != 0 => name.clone(),
_ => "inventory".into(),
}
}
fn set_menu_slot(&mut self, container: i32, slot: i32, item: Option<ItemStack>) {
let menu = self.open_menu_name(container);
if menu == "minecraft:lectern" {
if slot == 0 {
self.remote_lectern_book(item);
}
return;
}
if container != 0 && menu == "inventory" {
return;
}
let Some(internal) = Self::menu_slot(&menu, slot) else {
return;
};
if internal == 200 {
return; // the result slot is recomputed from the grid
}
*self.slot_mut(internal) = item;
if internal >= super::stations::INPUT && self.station.is_some() {
self.remote_station_slot_changed(internal);
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob: its maximum
/// health (the hearts drawn over its mount) takes the server's value, from
/// `AttributeInstance.calculateValue`.
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let catalog = &self.motion.attributes;
let Some(max_health) = catalog.id("minecraft:max_health") else {
return;
};
let Some(s) = values
.iter()
.find(|s| s.attribute == max_health.registry_id())
else {
return;
};
let amounts = |op: i32| {
s.modifiers
.iter()
.filter(move |m| m.operation == op)
.map(|m| m.amount)
};
let base = s.base + amounts(0).sum::<f64>();
let mut value = base + amounts(1).map(|a| base * a).sum::<f64>();
for amount in amounts(2) {
value *= 1.0 + amount;
}
let value = catalog.attribute(max_health).definition.sanitize(value);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MaxHealth,
value,
);
}
}
fn handle_packet(&mut self, packet: C) -> Result<()> {
match packet {
// ClientCommonPacketListenerImpl.handlePing runs on the game
// thread, after all preceding packets (especially teleports).
C::Ping(p) => self.send(S::Pong(p)),
// PingDebugMonitor.onPongReceived.
C::PongResponse(p) => {
let delay = self.debug_millis() - p.time;
self.debug_charts.ping.log(delay);
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
self.selected = 0;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
r.show_death_screen = p.show_death_screen;
}
self.streaming.radius =
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
self.enter_remote_dimension(&p.spawn, false);
let view = client_view_distance(self.settings.render_distance);
if let Some(r) = self.remote.as_mut() {
r.sent_view = view;
}
let mut info =
horizon_protocol::packets::configuration::ClientInformation::new(view);
info.chat_visibility = self.settings.chat_visibility as i32;
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
// A new player, without effects (the server sends them
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.enter_remote_dimension(&p.spawn, true);
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
r.death_message = None;
r.health = (20.0, 20, 5.0);
}
self.capture(true);
}
C::PlayerPosition(p) => self.remote_teleport(&p)?,
C::PlayerRotation(p) => {
self.yaw = if p.relative_yaw {
self.yaw + p.yaw
} else {
p.yaw
};
self.pitch = (if p.relative_pitch {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
}
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ForgetLevelChunk(p) => {
let [x, z] = p.pos;
self.streaming.forget(&mut self.world, x, z);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns([(x, z)]);
}
}
C::ChunkBatchFinished(_) => {
// ChunkBatchSizeCalculator's ceiling; meshing runs on workers.
self.send(S::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 64.0,
}));
}
C::BlockUpdate(p) => self.server_block(p.pos, p.state),
// ClientPacketListener.handleBlockDestruction: another player's crack.
C::BlockDestruction(p) => {
self.destroy_block_progress(p.id, p.pos, i32::from(p.progress));
}
C::SectionBlocksUpdate(p) => {
let [sx, sy, sz] = p.section;
for change in p.changes {
let local = (change & 0xFFF) as i32;
let pos = [
sx * 16 + (local >> 8 & 15),
sy * 16 + (local & 15),
sz * 16 + (local >> 4 & 15),
];
self.server_block(pos, (change >> 12) as i32);
}
}
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
// Level.blockEvent on the client level: the block's triggerEvent
// runs on the client's copy of whatever is at pos (a piston's
// moveBlocks builds the moving block entities it animates, a note
// block plays); what it doesn't handle reaches the client copy of
// the block entity (chest lids, shulker boxes).
C::BlockEvent(p) => {
let pos = horizon_core::position::BlockPos::new(p.pos[0], p.pos[1], p.pos[2]);
let triggered = self.with_level(|b, level, ticks| {
b.client_block_event(level, ticks, pos, i32::from(p.b0), i32::from(p.b1))
});
self.sync_moving_pistons();
self.process_level_events()?;
if !triggered {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let block = &self.res.facts().get(state).name;
let named = self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:block")?
.name(p.block)
.map(str::to_owned)
});
if named.as_deref() == Some(&**block) {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
}
}
}
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
// level plays them (particles and sounds: a spawner's burst, bone
// meal, waxing, a block broken by someone else).
// ClientPacketListener.handleLevelEvent: a global one is heard
// from anywhere (ClientLevel.globalLevelEvent).
C::LevelEvent(p) if p.global => self.global_level_event_sound(p.kind, p.pos),
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
C::LevelParticles(p) => self.remote_level_particles(&p),
C::Explode(p) => self.remote_explode(&p),
C::Sound(p) => {
if let Some(name) = self.remote_sound_name(&p.sound) {
let at = [
f64::from(p.x) / 8.0,
f64::from(p.y) / 8.0,
f64::from(p.z) / 8.0,
];
// ClientPacketListener.handleSoundEvent plays with packet.getSource().
match usize::try_from(p.source)
.ok()
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
{
Some(source) => {
self.audio
.play_on(source, &name, Some(at), p.volume, p.pitch)
}
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
}
}
}
C::StopSound(p) => self
.audio
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
C::AddEntity(p) => {
let kind = self
.remote
.as_ref()
.and_then(|r| {
r.builtin
.get("minecraft:entity_type")?
.name(p.kind)
.map(str::to_owned)
})
.unwrap_or_default();
let rotation =
[p.yaw, p.pitch, p.head_yaw].map(horizon_protocol::buf::unpack_degrees);
if let Some(r) = self.remote.as_mut() {
if kind == "minecraft:player" {
r.level_players.insert(p.id, p.uuid);
} else {
r.level_players.remove(&p.id);
}
}
// Entity.getTeam: a mob is on a team by its UUID.
self.persist.uuids.insert(
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
self.spawn_remote_projectile(
p.id,
&kind,
p.position,
(rotation[0], rotation[1]),
p.velocity,
p.data,
);
}
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
}
C::RemoveEntities(p) => {
for id in p.ids {
if let Some(r) = self.remote.as_mut() {
r.level_players.remove(&id);
}
self.remove_entity(id);
}
}
C::MoveEntityPos(play::MoveEntityPos(m))
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
| C::MoveEntityRot(play::MoveEntityRot(m)) => {
let base = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&m.id))
.map(|t| t.position);
let Some(base) = base else { return Ok(()) };
// VecDeltaCodec.decode: each step's position from the last.
let path = m.delta.as_ref().map(|d| match d {
play::VecDelta::Linear(_) => Path::Linear(d.end(base)),
play::VecDelta::Stepped(steps) => {
let mut at = base;
Path::Stepped(
steps
.iter()
.map(|(delta, ticks)| {
at = play::VecDelta::apply(at, *delta);
(at, *ticks)
})
.collect(),
)
}
});
let rotation = m.rotation.map(|(y, x)| {
(
horizon_protocol::buf::unpack_degrees(y),
horizon_protocol::buf::unpack_degrees(x),
)
});
self.move_entity(m.id, path, rotation, m.on_ground, false);
}
C::EntityPositionSync(p) => {
let path = match p.path {
play::PositionPath::Linear(at) => Path::Linear(at),
play::PositionPath::Stepped(steps) => Path::Stepped(steps),
};
self.move_entity(p.id, Some(path), Some((p.yaw, p.pitch)), p.on_ground, true)
}
C::TeleportEntity(p) => {
let base = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&p.id))
.map_or([0.0; 3], |t| t.position);
let rel = |bit: i32, i: usize| {
if p.relatives & bit != 0 {
base[i] + p.position[i]
} else {
p.position[i]
}
};
let position = [
rel(play::relative::X, 0),
rel(play::relative::Y, 1),
rel(play::relative::Z, 2),
];
self.move_entity(
p.id,
Some(Path::Linear(position)),
Some((p.yaw, p.pitch)),
p.on_ground,
false,
);
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
// lead (this player, another player, a mob or a fence knot), for
// the rope.
C::SetEntityLink(p) => {
let me = self.remote.as_ref().map(|r| r.entity_id);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
use horizon_client::mobs::leash::Holder;
m.extra.leash = match p.dest {
0 => None,
d if Some(d) == me => Some(Holder::Player),
d => match self.remote_knots.get(&(d as u32)) {
Some(&block) => Some(Holder::Knot(block)),
// Not a known knot yet: kept by id until the knot
// arrives (game/remote_objects.rs).
None => Some(Holder::Mob(d as u32)),
},
};
}
}
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
C::RotateHead(p) => {
if let Some(r) = self.remote.as_mut() {
let yaw = horizon_protocol::buf::unpack_degrees(p.head_yaw);
r.head_lerps.insert(p.id, (yaw, 3));
}
}
C::SetEntityData(p) => {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => {
m.invisible = flags & 0x20 != 0;
m.glowing = flags & 0x40 != 0;
}
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
text.as_ref().map(horizon_client::text::Text::plain);
m.custom_name_text = text;
}
(2, Value::Text(name)) => {
let text = horizon_client::entity_nbt::name_text(name);
m.custom_name = Some(text.plain());
m.custom_name_text = Some(text);
}
(3, Value::Bool(visible)) => m.name_visible = *visible,
_ => {}
}
}
}
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
&& let Some(r) = self.remote.as_mut()
{
if flags & 0x40 != 0 {
r.glowing.insert(p.id);
} else {
r.glowing.remove(&p.id);
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
for (index, _, value) in &p.values {
match (index, value) {
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED, DATA_IS_IGNITED.
(16, Value::Int(dir)) => m.extra.counter = *dir,
(17, Value::Bool(powered)) => m.extra.charged = *powered,
(18, Value::Bool(ignited)) => m.extra.flag = *ignited,
_ => {}
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
(7, Value::Int(ticks)) => m.ticks_frozen = *ticks,
// Strider.DATA_SUFFOCATING (after AgeableMob's two
// and DATA_BOOST_TIME): a cold strider shakes.
(19, Value::Bool(cold))
if m.species() == horizon_client::mobs::Species::Strider =>
{
m.extra.flag = *cold;
}
_ => {}
}
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
// too, remote_mob_data's apply reads index 8 above.
let mannequin = self
.mobs
.iter()
.any(|m| m.id == p.id as u32 && &*m.kind.id == "minecraft:mannequin");
let other = mannequin
|| self.remote.as_ref().is_some_and(|r| {
r.level_players.contains_key(&p.id) && r.entity_id != p.id
});
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
// player lies down in their bed (drawn lying in it), or gets up.
if other {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
let code = bed.map_or(0, |b| self.bed_facing(b));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.extra.ints[1] = code;
}
}
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
// player lies down in the bed, or gets up.
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
for (index, _, value) in &p.values {
match (index, value) {
(14, Value::OptPos(bed)) => {
if bed.is_some() && self.sleeping.is_none() {
self.sleep_timer = 0;
}
self.sleeping = *bed;
}
// Entity.DATA_SHARED_FLAGS_ID's sprinting bit: the
// server stops the sprint after a hit's extra
// knockback (the flag only, as assignValues sets
// it: the speed modifier and wasSprinting stay).
(0, Value::Byte(flags)) => {
if let Some(pl) = self.player.as_mut() {
pl.sprinting = flags & 0x08 != 0;
}
}
// Entity.DATA_AIR_SUPPLY_ID and
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
// and golden hearts the server's player has.
(1, Value::Int(air)) => {
if let Some(pl) = self.player.as_mut() {
pl.air = *air;
}
}
(17, Value::Float(amount)) => {
if let Some(pl) = self.player.as_mut() {
let _ = pl.internal_set_absorption(*amount);
}
}
_ => {}
}
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits:
// another player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag), and the dust a runner raises.
if (mannequin
|| self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
m.extra.crouching = flags & 2 != 0;
m.extra.sprinting = flags & 8 != 0;
}
}
}
for (index, _, value) in &p.values {
if *index == ITEM_ENTITY_ITEM
&& let Value::Item(stack) = value
{
self.remote_item_data(p.id, stack);
}
// ExperienceOrb.DATA_VALUE (the icon's size).
if *index == 8
&& let Value::Int(v) = value
&& let Some(o) = self.orbs.iter_mut().find(|o| o.id == p.id as u32)
{
o.value = (*v).max(0) as u32;
}
}
self.remote_object_data(p.id, &p.values);
self.remote_projectile_data(p.id, &p.values);
self.remote_dragon_data(p.id, &p.values);
}
// ClientPacketListener.handleSetEquipment: what a mob or another
// player holds and wears (EquipmentSlot ordinals).
C::SetEquipment(p) => {
self.remote_object_equipment(p.id, &p.slots);
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
.slots
.iter()
.map(|(slot, stack)| {
(
*slot,
stack.as_ref().map(|s| self.item_name(s.item)),
self.to_stack(stack),
)
})
.collect();
// CrossbowItem.isCharged: the main hand's `charged_projectiles`
// (AvatarRenderer.getArmPose holds a loaded crossbow up).
let charged = p.slots.iter().find(|(slot, _)| *slot == 0).map(|(_, s)| {
s.as_ref().is_some_and(|s| {
matches!(
s.component(horizon_protocol::item::component::CHARGED_PROJECTILES),
Some(horizon_protocol::item::Component::Stacks(list))
if !list.is_empty()
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
m.extra.charged = charged;
}
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
1 => e.off_hand = item,
2 => e.feet = item,
3 => e.legs = item,
4 => e.chest = item,
5 if !body => e.head = item,
6 if body => {
// A wolf's armor shows its cracks.
e.body_damage = stack.as_ref().map_or(0, |s| s.damage as i32);
e.head = item;
}
7 => m.set_saddle(stack),
_ => {}
}
}
}
}
C::TakeItemEntity(p) => {
// An orb's sound comes from the server, at the orb.
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
if !orb
&& self
.remote
.as_ref()
.is_some_and(|r| r.entity_id == p.player)
&& let Some(eye) = self.eye_position()
{
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(eye), 0.2, pitch);
}
// ItemPickupParticle toward the collector (this player, another one or a mob).
let collector = if self
.remote
.as_ref()
.is_some_and(|r| r.entity_id == p.player)
{
super::pickups::Collector::Player
} else {
super::pickups::Collector::Mob(p.player as u32)
};
if let Some(item) = self.dropped.iter().find(|d| d.id == p.item as u32).cloned() {
self.add_pickup(super::pickups::Taken::Item(Box::new(item)), collector);
} else if let Some(orb) = self.orbs.iter().find(|o| o.id == p.item as u32).cloned()
{
self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector);
}
// An orb stays: the server may still hold merged ones in it.
if !orb {
self.remove_entity(p.item);
}
}
// ClientPacketListener.handleSetEntityMotion: knockback on this player.
C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
}
}
// The same packet for another entity: Entity.lerpMotion.
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
// jolts the walk animation and restarts the hurt flash. Its hurt
// sound comes as a sound from Horizon's host, which plays it itself.
// This player's own hurt tilt and flash come from the server too
// (sent to the player itself): its own moves never hurt it.
C::DamageEvent(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt();
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::HurtAnimation(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
// Player.animateHurt: the tilt turns around the yaw.
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::EntityEvent(p) => {
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
if p.event == 2 {
self.remote_kinetic_hit(p.id);
}
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.ui.registries.permission = (p.event - 24) as u8;
self.remote_world_options_permission(p.event >= 26);
}
// Ravager.handleEntityEvent 69: the roar's puff and push.
if p.event == 69 {
self.remote_ravager_roar(p.id);
}
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.health = 0.0;
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
}
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
// Mob.handleEntityEvent 20: the spawn poof.
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
self.poof_particles(p.id as u32);
}
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
// 7, Animal and Allay 18: the particles about the mob.
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) {
self.entity_event_particles(p.id as u32, p.event);
}
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
// the projectile is (the server removes it right after).
if p.event == 3 {
let crumbs = self
.creatures
.projectiles
.iter()
.find(|pr| pr.id == p.id as u32)
.and_then(|pr| {
let item = match pr.kind {
horizon_client::projectiles::ProjectileKind::Snowball => {
"minecraft:snowball"
}
horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg",
_ => return None,
};
Some(([pr.position.x, pr.position.y, pr.position.z], item))
});
if let Some((at, item)) = crumbs {
self.item_burst(at, item);
}
}
if p.event == super::combat::DROWN_EVENT {
self.remote_drown(p.id);
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.player
.as_ref()
.map(|q| [q.position.x, q.position.y, q.position.z])
} else {
self.mobs
.iter()
.find(|m| m.id == p.id as u32)
.map(|m| [m.position.x, m.position.y, m.position.z])
};
if let Some(at) = at {
self.honey_slide_particles(at);
}
}
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.remote_complete_using();
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
// (LivingEntity.swing, which a swing in its first half ignores).
C::SwingAnimation(p) => {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let animation = horizon_client::first_person::SwingAnimation {
kind: p.kind.clamp(0, 255) as u8,
duration: p.duration,
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation.duration);
}
}
// The player's own instances: its maximum health (the hearts the
// HUD draws and the health it may hold), reach, speed and so on.
// Then the received health again, which the old maximum clamped.
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
}
self.apply_remote_health();
}
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
&& let Some(id) = self.remote_effect_id(p.effect)
{
self.potions.effects.list.retain(|e| e.id != id);
}
}
C::ChangeDifficulty(p) => {
self.ui.difficulty = p.difficulty.clamp(0, 3) as u8;
self.level.difficulty_locked = p.locked;
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
}
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
C::SetHealth(p) => {
let flash = self.remote.as_mut().is_some_and(|r| {
r.health = (p.health, p.food, p.saturation);
std::mem::replace(&mut r.flash_on_set_health, true)
});
if flash && let Some(pl) = self.player.as_mut() {
pl.health.flash_health_change(p.health);
}
self.apply_remote_health();
// The death screen opens (or the respawn is asked for) with the
// death packet when the rule hides it.
let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen);
if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some()
{
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
}
}
C::PlayerCombatKill(p) => {
// The component resolves in the client's current language, as
// the chat line does, not in the language the session joined
// with (`Remote.lang` is the vanilla server-language table).
let message = remote_text(&p.message);
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
if let Some(r) = self.remote.as_mut() {
r.death_message = Some(message);
}
// ClientPacketListener.handlePlayerCombatKill: the death
// screen, whatever health was last sent, or `respawn()` at
// once when it is hidden.
if me && self.screen != Screen::Dead && self.player.is_some() {
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
} else {
self.respawn_player()?;
}
}
}
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
C::SetExperience(p) => {
self.experience = Experience {
level: p.level,
progress: p.progress,
total: p.total,
}
}
// ClientPacketListener.handleSetTime: the game time, then
// ClientClockManager.handleUpdates: the clock catches up with the
// game time and takes the server's state.
C::SetTime(p) => {
self.world.game_time = p.game_time;
self.remote_clock_tick();
let clock = {
let r = self.remote.as_ref();
let names =
r.and_then(|r| r.registries.registries.get("minecraft:world_clock"));
let wanted = r.map(|r| r.dimension.name.clone()).unwrap_or_default();
p.clocks
.iter()
.find(|c| {
names
.and_then(|n| n.get(c.clock as usize))
.is_some_and(|(n, _)| *n == wanted)
})
.or_else(|| p.clocks.first())
.cloned()
};
if let Some(c) = clock {
self.world.day_time = c.total_ticks;
if let Some(r) = self.remote.as_mut() {
r.clock = Some(c.clock);
r.clock_rate = c.rate;
r.clock_partial = c.partial_tick;
}
}
}
C::GameEvent(p) => match p.event {
// GUARDIAN_ELDER_EFFECT: the apparition, and the curse sound
// unless the elder guardian is silent.
10 => {
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
if p.param == 1.0
&& let Some(at) = self.player.as_ref().map(|m| m.position)
{
self.audio.play(
"minecraft:entity.elder_guardian.curse",
Some([at.x, at.y, at.z]),
1.0,
1.0,
);
}
}
// WIN_GAME: the poem and credits, then PERFORM_RESPAWN.
4 if self.screen != Screen::Win => {
self.open_win_screen(true, super::win::Finish::RemoteRespawn);
}
1 => {
self.world.weather.raining = true;
self.world.weather.rain_level = (0.0, 0.0);
}
2 => {
self.world.weather.raining = false;
self.world.weather.rain_level = (1.0, 1.0);
}
3 => {
let mode = p.param as i32;
self.set_mode_id(mode);
if let Some(r) = self.remote.as_mut() {
r.game_mode = mode;
}
}
// IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen.
11 => {
if let Some(r) = self.remote.as_mut() {
r.show_death_screen = p.param == 0.0;
}
}
7 => {
self.world.weather.rain_level = (p.param, p.param);
self.world.weather.raining = p.param > 0.0;
}
8 => {
self.world.weather.thunder_level = (p.param, p.param);
self.world.weather.thundering = p.param > 0.0;
}
_ => {}
},
C::PlayerAbilities(p) => {
self.creative = p.flags & 8 != 0;
if let Some(r) = self.remote.as_mut() {
r.abilities = Some(p);
}
self.apply_remote_abilities();
}
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
// carried index stays, so the next tick answers with SetCarriedItem.
C::SetHeldSlot(p) => {
if (0..9).contains(&p.slot) {
self.selected = p.slot as usize;
}
}
C::ContainerSetContent(p) => {
if let Some(r) = self.remote.as_mut() {
r.state_id = p.state;
}
for (slot, item) in p.items.iter().enumerate() {
let stack = self.to_stack(item);
self.set_menu_slot(p.container, slot as i32, stack);
}
self.carried = self.to_stack(&p.carried);
self.remote_synced();
}
C::ContainerSetSlot(p) => {
if let Some(r) = self.remote.as_mut() {
r.state_id = p.state;
}
let stack = self.to_stack(&p.item);
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
// gains items squeezes for 5 ticks.
if p.container == 0
&& (36..=45).contains(&p.slot)
&& let Some(new) = &stack
{
let (pop, old) = match p.slot {
45 => (9, self.offhand.as_ref()),
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
};
if old.is_none_or(|old| old.count < new.count) {
self.hotbar_pop[pop] = 5;
}
}
self.set_menu_slot(p.container, i32::from(p.slot), stack);
self.remote_synced();
}
C::SetCursorItem(p) => self.carried = self.to_stack(&p.item),
C::SetPlayerInventory(p) => {
let stack = self.to_stack(&p.item);
match p.slot {
0..=35 => self.inventory[p.slot as usize] = stack,
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
// Inventory.SLOT_OFFHAND.
40 => self.offhand = stack,
_ => {}
}
self.remote_synced();
}
C::ContainerSetData(p) if self.open_menu_name(p.container) == "minecraft:lectern" => {
self.remote_lectern_page(i32::from(p.value));
}
C::ContainerSetData(p) if self.remote_station_open(p.container) => {
self.remote_station_data(usize::try_from(p.id).unwrap_or(usize::MAX), p.value);
}
C::ContainerSetData(p) => {
let v = i32::from(p.value);
match self
.container
.and_then(|key| self.world.block_entities.get_mut(&key))
{
Some(BlockEntity::Furnace(f)) => match p.id {
0 => f.lit_remaining = v,
1 => f.lit_total = v,
2 => f.cooking_timer = v,
3 => f.cooking_total = v,
_ => {}
},
// CrafterBlockEntity.containerData.
Some(BlockEntity::Crafter(c)) => match usize::try_from(p.id) {
Ok(k @ 0..9) => c.disabled[k] = v == 1,
Ok(9) => c.triggered = v == 1,
_ => {}
},
// BrewingStandBlockEntity.dataAccess.
Some(BlockEntity::Brewing(b)) => match p.id {
0 => b.brew_time = v,
1 => b.fuel = v,
2 => b.total_brew_time = v,
3 => b.total_fuel = v,
_ => {}
},
_ => {}
}
}
C::OpenScreen(p) => self.remote_open_screen(p),
C::MountScreenOpen(p) => self.remote_open_mount(p),
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
C::MerchantOffers(p) => self.remote_merchant_offers(p),
C::ContainerClose(_) if self.remote_lectern_closed() => {}
C::ContainerClose(_) => {
if self.screen == Screen::Inventory {
self.remote_close_local();
self.screen = Screen::Playing;
self.capture(true);
}
}
C::Unknown(id, data)
if id == horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS =>
{
self.handle_command_suggestions(&data);
}
C::Commands(p) => {
let tree = horizon_client::command::network::from_packet(
&p,
horizon_client::command::vanilla::dispatcher(),
);
if let Some(r) = self.remote.as_mut() {
r.commands = Some(tree);
}
self.merge_remote_tree();
}
C::Cooldown(p) => {
// ClientPacketListener.handleItemCooldown: the server starts
// (or confirms) a group's cooldown; 0 ends it.
let group: Arc<str> = Arc::from(p.group.as_str());
if p.duration == 0 {
self.cooldowns.remove(&group);
} else {
self.cooldowns.add(group, p.duration);
}
}
C::SystemChat(p) => {
// The component as sent, translated in the client's own
// language. ChatListener.handleOverlay for the action bar.
let text = remote_text(&p.content);
if p.overlay {
self.titles.set_overlay(text, false);
} else {
self.chat_line(text, horizon_client::chat::ChatSource::Server);
}
}
C::PlayerChat(p) => {
use horizon_client::scoreboard::tag_to_json;
let content = p
.unsigned_content
.as_ref()
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line, horizon_client::chat::ChatSource::Player);
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line, horizon_client::chat::ChatSource::Player);
}
C::PlayerInfoUpdate(p) => {
if let Some(r) = self.remote.as_mut() {
// handlePlayerInfoUpdate: new entries first, then every
// action on every known entry.
if p.actions & 1 != 0 {
for e in &p.entries {
if let Some(name) = &e.name {
r.tab_players.entry(e.uuid).or_insert_with(|| {
horizon_client::tab_list::TabPlayer::new(e.uuid, name.clone())
});
if let Some(t) = e.properties.iter().find(|p| p.name == "textures")
{
// Without the services' profile property keys a
// signature can't be checked; a signed property
// counts as genuine.
use horizon_client::skins::{self, Signature};
let signature = if t.signature.is_some() {
Signature::Signed
} else {
Signature::Unsigned
};
let textures =
skins::unpack(&t.value, signature, &skins::TEXTURE_URIS);
r.skin_textures.entry(e.uuid).or_insert(textures);
}
}
}
}
for e in p.entries {
if let Some(t) = r.tab_players.get_mut(&e.uuid) {
if let Some(mode) = e.game_mode {
t.game_mode = mode;
}
if let Some(latency) = e.latency {
t.latency = latency;
}
if let Some(name) = &e.display_name {
t.display_name =
name.as_ref().map(horizon_client::scoreboard::tag_to_json);
}
if let Some(order) = e.list_order {
t.list_order = order;
}
if let Some(hat) = e.show_hat {
t.show_hat = hat;
}
match e.listed {
Some(true) => {
r.listed.insert(e.uuid);
}
Some(false) => {
r.listed.remove(&e.uuid);
}
None => {}
}
}
if let Some(name) = e.name {
r.players.insert(e.uuid, name);
}
}
}
}
C::PlayerInfoRemove(p) => {
if let Some(r) = self.remote.as_mut() {
for u in p.uuids {
r.players.remove(&u);
r.tab_players.remove(&u);
r.skin_textures.remove(&u);
r.listed.remove(&u);
}
}
}
C::TabList(p) => {
// handleTabListCustomisation: empty text clears it.
let text = |t: &Tag| {
let json = horizon_client::scoreboard::tag_to_json(t);
let text = horizon_client::scoreboard::comp_text(&json);
(!text.plain().is_empty()).then_some(text)
};
self.tab_list.header = text(&p.header);
self.tab_list.footer = text(&p.footer);
}
C::InitializeBorder(p) => {
let b = &mut self.border;
b.set_center([p.center_x, p.center_z]);
if p.lerp_time > 0 {
b.lerp_between(p.old_size, p.new_size, p.lerp_time);
} else {
b.set_size(p.new_size);
}
b.set_absolute_max_size(p.absolute_max_size);
b.warning_distance = p.warning_blocks;
b.warning_time = p.warning_time;
}
C::SetBorderCenter(p) => self.border.set_center([p.x, p.z]),
C::SetBorderLerpSize(p) => {
self.border
.lerp_between(p.old_size, p.new_size, p.lerp_time)
}
C::SetBorderSize(p) => self.border.set_size(p.size),
C::SetBorderWarningDelay(p) => self.border.warning_time = p.delay,
C::SetBorderWarningDistance(p) => self.border.warning_distance = p.blocks,
C::SetObjective(p) => self.remote_objective(p),
C::SetDisplayObjective(p) => {
// DisplaySlot ids: list, sidebar, below_name, then the team sidebars.
let slot = match p.slot {
0 => "list".to_owned(),
2 => "below_name".to_owned(),
n @ 3..=18 => format!(
"sidebar.team.{}",
horizon_client::text::COLORS[(n - 3) as usize].1
),
_ => "sidebar".to_owned(),
};
let board = &mut self.score.board;
let objective = board.objective(&p.objective).map(|o| o.name.clone());
board.set_display_objective(&slot, objective.as_deref());
}
C::SetScore(p) => {
let board = &mut self.score.board;
if board.objective(&p.objective).is_some() {
let s = board.score_mut(&p.owner, &p.objective);
s.value = p.score;
s.display = p
.display
.as_ref()
.map(horizon_client::scoreboard::tag_to_json);
s.number_format = p.number_format.as_ref().map(number_format);
}
}
C::ResetScore(p) => match &p.objective {
Some(o) => self.score.board.reset_one(&p.owner, o),
None => self.score.board.reset_all(&p.owner),
},
C::SetPlayerTeam(p) => self.remote_team(p),
C::SetChunkCacheRadius(p) => {
self.streaming.radius = self.settings.render_distance.min(p.distance + 1).max(2);
if let Some(r) = self.remote.as_mut() {
r.view_distance = p.distance;
}
// ClientChunkCache.updateViewRadius: a new storage of that size.
self.drop_remote_chunks_out_of_range();
}
C::SetChunkCacheCenter(p) => {
if let Some(r) = self.remote.as_mut() {
r.chunk_center = (p.x, p.z);
}
self.drop_remote_chunks_out_of_range();
}
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
C::OpenSignEditor(p) => self.remote_open_sign_editor(p),
C::OpenBook(p) => self.remote_open_book(p.hand),
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
C::SetDefaultSpawnPosition(p) => {
let [x, y, z] = p.pos.pos;
self.spawn = (p.pos.dimension == "minecraft:overworld")
.then(|| Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5));
}
C::ServerData(p) => self.remote_server_data(p.icon.as_deref()),
C::MapItemData(p) => self.remote_map_item_data(&p),
// handleTickingState / handleTickingStep: the level's TickRateManager.
C::TickingState(p) => {
self.clock.set_rate(p.rate);
self.clock.set_frozen(p.frozen);
}
C::TickingStep(p) => self.clock.set_frozen_ticks_to_run(p.steps),
C::UpdateAdvancements(p) => {
let icons = p
.added
.iter()
.map(|a| {
a.display
.as_ref()
.map_or_else(String::new, |d| self.item_name(d.icon.item).to_string())
})
.collect();
self.remote_advancements(p, icons);
}
// handleAwardStats: StatsCounter.setValue for each.
C::AwardStats(p) => {
let Some(r) = self.remote.as_ref() else {
return Ok(());
};
let mut known = Vec::new();
for (ty, value, count) in p.stats {
let Some(ty) = r
.builtin
.get("minecraft:stat_type")
.and_then(|t| t.name(ty))
else {
continue;
};
let values = match ty {
"minecraft:mined" => "minecraft:block",
"minecraft:killed" | "minecraft:killed_by" => "minecraft:entity_type",
"minecraft:custom" => "minecraft:custom_stat",
_ => "minecraft:item",
};
if let Some(key) = r.builtin.get(values).and_then(|v| v.name(value)) {
known.push((ty.to_owned(), key.to_owned(), count));
}
}
for (ty, key, count) in known {
self.stats.values.entry(ty).or_default().insert(key, count);
}
}
// ClientPacketListener.setActionBarText, setTitleText,
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
C::BossEvent(p) => self.apply_boss_event(&p),
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
C::SetTitlesAnimation(p) => self.titles.set_times(p.fade_in, p.stay, p.fade_out),
// ClientCommonPacketListenerImpl.handleShowDialog / handleClearDialog.
C::ShowDialog(p) => self.show_play_dialog(&p.dialog),
C::ClearDialog(_) => self.clear_dialog(),
C::ClearTitles(p) => {
self.titles.clear();
if p.value {
self.titles.reset_times();
}
}
_ => {}
}
Ok(())
}
/// `handleAddObjective`: add, remove or change an objective.
fn remote_objective(&mut self, p: play::SetObjective) {
use horizon_client::scoreboard::{RenderType, tag_to_json};
let board = &mut self.score.board;
match (p.method, p.display) {
(1, _) => board.remove_objective(&p.name),
(method, Some((name, render, format))) => {
let render = if render == 1 {
RenderType::Hearts
} else {
RenderType::Integer
};
let format = format.as_ref().map(number_format);
if method == 0 {
board.add_objective(
&p.name,
"dummy",
tag_to_json(&name),
render,
false,
format,
);
} else if let Some(o) = board.objective_mut(&p.name) {
o.display_name = tag_to_json(&name);
o.render_type = render;
o.number_format = format;
}
}
_ => {}
}
}
/// `handleSetPlayerTeamPacket`.
fn remote_team(&mut self, p: play::SetPlayerTeam) {
use horizon_client::scoreboard::{CollisionRule, Visibility, tag_to_json};
let board = &mut self.score.board;
if p.method == 1 {
board.remove_team(&p.name);
return;
}
if p.method == 0 {
board.add_team(&p.name);
}
if let Some(params) = &p.parameters
&& let Some(team) = board.team_mut(&p.name)
{
team.display_name = tag_to_json(&params.display_name);
team.prefix = tag_to_json(&params.prefix);
team.suffix = tag_to_json(&params.suffix);
team.color = params
.color
.and_then(|c| horizon_client::text::COLORS.get(c as usize))
.map(|c| c.1.to_owned());
team.allow_friendly_fire = params.options & 1 != 0;
team.see_friendly_invisibles = params.options & 2 != 0;
team.nametag_visibility = Visibility::ALL
.get(params.name_tag_visibility as usize)
.copied()
.unwrap_or(Visibility::Always);
team.collision_rule = CollisionRule::ALL
.get(params.collision_rule as usize)
.copied()
.unwrap_or(CollisionRule::Always);
}
match p.method {
0 | 3 => {
for player in &p.players {
board.add_to_team(player, &p.name);
}
}
4 => {
for player in &p.players {
board.remove_from_team(player);
}
}
_ => {}
}
}
/// The player list's view of the connection: the listed players (with
/// their downloaded skins once ready), online mode, and which players
/// are in the level with an upside-down name.
pub(super) fn remote_tab_players(
&mut self,
) -> Option<(Vec<horizon_client::tab_list::TabPlayer>, bool, Vec<u128>)> {
self.update_remote_skins();
let mut players: Vec<_> = {
let r = self.remote.as_ref()?;
r.listed
.iter()
.filter_map(|u| r.tab_players.get(u).cloned())
.collect()
};
for p in &mut players {
p.skin = self
.remote
.as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
}
let r = self.remote.as_ref()?;
let upside_down = players
.iter()
.filter(|p| {
(p.name == "Dinnerbone" || p.name == "Grumm")
&& (p.uuid == r.profile || r.level_players.values().any(|u| *u == p.uuid))
})
.map(|p| p.uuid)
.collect();
Some((players, r.online_mode, upside_down))
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
let auth = horizon_client::auth::current()?;
let state = self
.skin_profiles
.borrow_mut()
.state(&NameOrId::Id(auth.uuid));
let Resolve::Done(Some(profile)) = state else {
return None;
};
let (value, signature) = profile.textures()?;
Some(skins::unpack(&value, signature, &skins::TEXTURE_URIS))
}
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
/// never in the tab list: resolve each profile's textures and give the
/// level's player models their skin.
fn update_remote_skins(&mut self) {
let Some(r) = self.remote.as_ref() else {
return;
};
let local = r.profile;
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
.skin_textures
.iter()
.map(|(uuid, textures)| (*uuid, textures.clone()))
.collect();
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
// services, gives the local player its skin where the server sends none (single
// player's offline-mode server).
if !entries.iter().any(|(u, t)| *u == local && t.skin.is_some())
&& let Some(own) = self.own_textures()
{
entries.retain(|(u, _)| *u != local);
entries.push((local, own));
}
let mut resolved: Vec<(u128, String)> = Vec::new();
// PlayerSkin.model of each resolved skin: the textures' `slim`.
let mut slim = std::collections::HashSet::new();
for (uuid, textures) in entries {
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
horizon_client::skins::lookup(&textures, uuid != local, |kind, url| {
downloads.state(kind, url)
})
};
if let Some(skin) = skin {
if textures.slim {
slim.insert(uuid);
}
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
// PlayerInfo.getSkin: the downloaded skin and its model, else the
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
Some(skin) => (skin.clone(), slim.contains(&uuid)),
None => {
let skin = horizon_client::tab_list::default_skin(uuid);
(skin.to_owned(), skin.contains("/slim/"))
}
};
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
r.level_players
.iter()
.map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(),
r.skins.clone(),
r.profile,
)
}) else {
return;
};
for (id, uuid) in level {
let (skin, is_slim) = skin_of(&skins, uuid);
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
{
mob.skin = Some(skin);
mob.slim = is_slim;
}
}
if let Some(mob) = self.player_model.as_mut() {
let (skin, is_slim) = skin_of(&skins, own);
mob.skin = Some(skin);
mob.slim = is_slim;
}
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a
// player's, never required to be signed.
let mannequins: Vec<(u32, horizon_client::skins::Textures)> = self
.mobs
.iter()
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
.map(|m| {
let profile = m.profile.as_ref();
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = if let Some(t) = t {
let signature = if t.signature.is_some() {
horizon_client::skins::Signature::Signed
} else {
horizon_client::skins::Signature::Unsigned
};
horizon_client::skins::unpack(
&t.value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
} else {
// `ResolvableProfile.resolveProfile`: no `textures` and
// only a name or an id resolves through the services.
let key = profile.and_then(|p| match (&p.name, p.uuid) {
(Some(name), None) => {
Some(horizon_client::skins::NameOrId::Name(name.clone()))
}
(None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)),
_ => None,
});
key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) {
horizon_client::skins::Resolve::Done(Some(profile)) => {
profile.textures().map(|(value, signature)| {
horizon_client::skins::unpack(
&value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
})
}
_ => None,
})
.unwrap_or_default()
};
(m.id, textures)
})
.collect();
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
for (id, textures) in mannequins {
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
horizon_client::skins::lookup(&textures, false, |kind, url| {
downloads.state(kind, url)
})
};
let slim = skin.is_some() && textures.slim;
resolved.push((id, skin, slim));
}
for (id, skin, slim) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
{
mob.slim = slim && skin.is_some();
mob.skin = skin;
}
}
}
/// Puts a downloaded skin in an atlas skin slot: a free one, else the
/// least recently shown.
pub(super) fn store_skin(&mut self, location: String, rgba: Vec<u8>) {
use horizon_client::hud::SKIN_SLOTS;
let slot = if self.skin_slots.len() < SKIN_SLOTS {
self.skin_slots.len()
} else {
let (old, slot) = self.skin_slots.remove(0);
self.gui.atlas.sprites.remove(&old);
slot
};
let Some(rect) = self
.gui
.atlas
.sprites
.get(&format!("horizon/skin{slot}"))
.copied()
else {
return;
};
let (w, h) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
self.map_patches.push(horizon_client::font::Patch {
x: (rect.u0 * w).round() as u32,
y: (rect.v0 * h).round() as u32,
width: 64,
height: 64,
rgba,
});
self.gui.atlas.sprites.insert(location.clone(), rect);
self.skin_slots.push((location, slot));
}
/// Whether a downloaded skin is in the atlas, marking it recently shown.
fn show_skin(&mut self, location: &str) -> bool {
let Some(i) = self.skin_slots.iter().position(|e| e.0 == location) else {
return false;
};
let e = self.skin_slots.remove(i);
self.skin_slots.push(e);
true
}
/// `show_skin`, or — its slot evicted — the image queued for upload again.
fn again_skin(&mut self, location: &str) -> bool {
self.show_skin(location) || self.skin_downloads.borrow_mut().again(location)
}
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
/// ChatType.Bound.decorate: the chat type's translation, with the
/// sender, the target and the message as its parameters, in its style.
fn format_chat(
&self,
bound: &play::ChatTypeBound,
content: serde_json::Value,
) -> horizon_client::text::Text {
use horizon_client::scoreboard::{comp_text, tag_to_json};
let Some(r) = self.remote.as_ref() else {
return comp_text(&content);
};
let decoration = r
.registries
.data("minecraft:chat_type", bound.chat_type)
.and_then(|t| t.get("chat"));
let key = match decoration.and_then(|d| d.get("translation_key")) {
Some(Tag::String(s)) => s.to_string_lossy(),
_ => "chat.type.text".into(),
};
let parameter = |name: &str| match name {
"sender" => tag_to_json(&bound.name),
"target" => bound
.target
.as_ref()
.map_or_else(|| serde_json::Value::from(""), tag_to_json),
_ => content.clone(),
};
let with: Vec<serde_json::Value> = match decoration.and_then(|d| d.get("parameters")) {
Some(Tag::List(list)) => list
.iter()
.map(|p| match p {
Tag::String(s) => parameter(&s.to_string_lossy()),
_ => serde_json::Value::from(""),
})
.collect(),
_ => vec![parameter("sender"), parameter("content")],
};
let mut component = match decoration.and_then(|d| d.get("style")) {
Some(style @ Tag::Compound(_)) => tag_to_json(style),
_ => serde_json::json!({}),
};
component["translate"] = key.into();
component["with"] = with.into();
comp_text(&component)
}
fn remote_item_data(&mut self, id: i32, stack: &Option<Stack>) {
let Some(stack) = self.to_stack(stack) else {
return;
};
let position = self
.remote
.as_mut()
.and_then(|r| r.pending_items.remove(&id));
if let Some(item) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
item.stack = stack;
return;
}
let Some((p, velocity)) = position else {
return;
};
let at = Vec3::new(p[0], p[1], p[2]);
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: id as u32,
stack,
position: at,
previous: at,
velocity,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset,
ticks: 0,
});
}
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
/// entities whose own tick moves them on a client: dropped items, falling
/// blocks and primed TNT.
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
r.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
.and_then(|r| r.pending_items.get_mut(&id))
{
p.1 = v;
} else if !self.potions.remote_thrown_motion(id as u32, v) {
self.end.remote_eye_motion(id as u32, velocity);
}
}
fn apply_remote_health(&mut self) {
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
return;
};
if let Some(p) = self.player.as_mut() {
if (p.health.health() - health).abs() > f32::EPSILON {
let _ = p.set_health(health.max(0.0), false);
}
p.health.food.food = food;
p.health.food.saturation = saturation;
}
}
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
/// simulates the ghast it rides, so its copy is where the server has it;
/// this window's copy trails the server's by the stepped interpolation and
/// would rise into the rider's feet, which lets the rider fall through.
/// Put the copy under the landing spot.
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
else {
return;
};
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
// Only a teleport that is that dismount: right above the ghast.
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|| (at.y - m.position.y).abs() > 5.0
{
return;
}
m.position = at;
m.previous = at;
if let Some(handler) = self
.remote
.as_mut()
.and_then(|r| r.stepped.get_mut(&(id as i32)))
{
handler.cancel();
}
}
/// ClientPacketListener.handleMovePlayer.
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
use play::relative as rel;
let current = self
.player
.as_ref()
.map(|pl| pl.position)
.unwrap_or(Vec3::ZERO);
let velocity = self
.player
.as_ref()
.map(|pl| pl.velocity)
.unwrap_or(Vec3::ZERO);
let pick = |bit: i32, base: f64, v: f64| if p.relatives & bit != 0 { base + v } else { v };
let position = Vec3::new(
pick(rel::X, current.x, p.position[0]),
pick(rel::Y, current.y, p.position[1]),
pick(rel::Z, current.z, p.position[2]),
);
let yaw = if p.relatives & rel::Y_ROT != 0 {
self.yaw + p.yaw
} else {
p.yaw
};
let pitch = (if p.relatives & rel::X_ROT != 0 {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
let delta = horizon_client::player_sync::teleport_velocity(
velocity,
(self.yaw, self.pitch),
(yaw, pitch),
p,
);
match self.player.as_mut() {
Some(player) => {
player.position = position;
player.velocity = delta;
}
None => {
let mut player = PlayerMotion::new(self.motion, position)?;
// Player.tick derives noPhysics from the current game mode.
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
self.sway = Default::default();
self.eye = 1.62;
self.previous_eye = 1.62;
self.clock.reset();
self.apply_remote_health();
self.apply_remote_abilities();
}
}
self.previous_position = position;
self.yaw = yaw;
self.pitch = pitch;
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
self.place_ghast_under(id, position);
}
if let Some(r) = self.remote.as_mut() {
// handleMovePlayer does not change LocalPlayer.sendPosition's
// baseline: the next tick must still report the corrected state.
r.destroying = None;
}
self.mining = None;
self.send(S::AcceptTeleportation(play::AcceptTeleportation {
id: p.teleport,
x: position.x,
y: position.y,
z: position.z,
yaw,
pitch,
}));
Ok(())
}
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// Client mods tick on any server, single player's own included.
self.tick_mods();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
self.send(S::ClientTickEnd(Empty));
}
Ok(())
}
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
// AbstractSignEditScreen.tick: the editor closes once its sign is removed.
if let Some(edit) = &self.sign_edit
&& !matches!(
self.world.block_entities.get(&edit.pos),
Some(BlockEntity::Sign(_))
)
{
self.close_sign_editor();
}
self.track_experience_display();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
let visibility = self.settings.chat_visibility as i32;
if let Some(r) = self.remote.as_mut()
&& r.joined
&& (r.sent_view != view || r.sent_chat_visibility != visibility)
{
r.sent_view = view;
r.sent_chat_visibility = visibility;
let mut info = horizon_protocol::packets::configuration::ClientInformation::new(view);
info.chat_visibility = visibility;
self.send(S::ClientInformation(info));
}
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
// the time only while the level runs normally.
let running = self.clock.world_runs();
self.boss_overlay.tick_darken(running);
if running {
self.world.game_time += 1;
self.remote_clock_tick();
}
// Weather levels drift toward their targets between server updates.
let w = &mut self.world.weather;
w.rain_level.0 = w.rain_level.1;
w.thunder_level.0 = w.thunder_level.1;
if self.use_delay > 0 {
self.use_delay -= 1;
}
// Minecraft.handleKeybinds precedes entity ticking and sendChanges.
// Window callbacks only queue clicks, so an interaction cannot leak
// between CLIENT_TICK_END and a later pong.
self.remote_handle_input();
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
// handleKeybinds: this path replaces `tick_world`'s for a server's
// client (multiplayer and the default single player), so the entries
// advance here or the slot stays shaded until the server says the
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
// a client. Ticking it after the keybinds keeps a held use from
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
for pop in &mut self.hotbar_pop {
*pop = pop.saturating_sub(1);
}
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
self.tick_remote_guardian_eyes(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
self.tick_block_entity_anims();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
self.tick_hand();
self.tick_particles();
self.tick_client_ambient();
self.tick_client_effects();
// Player.tick's sleep counter (the darkening and the wake-up fade).
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
// Gui.tick: the titles, the action bar and the held item's name.
self.tick_hud();
self.tick_subtitles();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
let (cx, cz) = (
(player.position.x.floor() as i32) >> 4,
(player.position.z.floor() as i32) >> 4,
);
if !self.streaming.is_lit(cx, cz) {
// LocalPlayer.tick can wait for chunks, but Minecraft.tick still
// calls sendChanges: rotations and the 20-tick position reminder
// must continue, as well as moves into the unloaded area.
if let Some(r) = self.remote.as_ref()
&& r.loaded_sent
{
let input = r.last_input;
self.remote_send_state(input);
}
return Ok(());
}
if self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
// ClientPacketListener: the level around the player is ready.
self.send(S::PlayerLoaded(Empty));
if let Some(r) = self.remote.as_mut() {
r.loaded_sent = true;
}
self.capture_loaded_world();
}
let stick = self.pad_move();
if self.player.is_none() {
return Ok(());
}
let playing = self.screen == Screen::Playing;
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
forward: held(KeyCode::KeyW),
backward: held(KeyCode::KeyS),
left: held(KeyCode::KeyA),
right: held(KeyCode::KeyD),
jump: held(KeyCode::Space),
shift: held(KeyCode::ShiftLeft),
sprint: held(KeyCode::ControlLeft),
analog: None,
}
.with_stick(stick.filter(|_| playing));
let input = u8::from(keys.forward)
| u8::from(keys.backward) << 1
| u8::from(keys.left) << 2
| u8::from(keys.right) << 3
| u8::from(keys.jump) << 4
| u8::from(keys.shift) << 5
| u8::from(keys.sprint) << 6;
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
// The server sends no passenger's position: they sit where their
// vehicles are now.
self.position_riders();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
if !riding {
self.previous_position = player.position;
}
let before = (
player.ground.on_ground,
[player.velocity.x, player.velocity.y, player.velocity.z],
[player.position.x, player.position.y, player.position.z],
);
let was_flying = player.flying;
self.sync_player_body();
// LivingEntity.tickDeath: the local player counts its deathTime once
// per client tick while dead (the camera's death roll and zoom).
if self.screen == Screen::Dead {
self.ui.death_time = self.ui.death_time.saturating_add(1);
}
let Some(player) = self.player.as_mut() else {
return Ok(());
};
let was_gliding = player.fall_flying;
let dead = self.screen == Screen::Dead;
// LivingEntity.isImmobile: a sleeper lies still in the bed.
if let Some(head) = self.sleeping {
player.position = Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5625,
f64::from(head[2]) + 0.5,
);
player.velocity = Vec3::ZERO;
} else if !dead && !riding {
player.client_side = true;
player.velocity = player.velocity.add_vector(pushed);
MotionWorld {
entities: &entity_collisions,
player,
scene: &mut self.world,
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
// at 10, the hurt tilt restarted every frame and shook the camera.
player.health.advance_clock();
}
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
let flying = player.flying;
// LocalPlayer.aiStep: a glide this client started is sent to the
// server (ServerboundPlayerCommandPacket START_FALL_FLYING).
let started_gliding = player.fall_flying && !was_gliding;
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission { state, muffled, .. } => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if muffled {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
};
self.audio.play(&sounds.step, Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::Swim { volume, pitch } => {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
// HoneyBlock.maybeDoSlideEffects on the client's own level:
// the slide sound, heard here (LocalPlayer.playSound); the
// particles come from the server's event 53.
horizon_client::motion::MotionEvent::HoneySlide { sound: true, .. } => {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
_ => {}
}
}
// The server owns health and hunger.
self.apply_remote_health();
self.tick_avatar(before, false);
// The player's own model (third person, the inventory's preview).
self.tick_player_model();
if !dead {
self.remote_tick_using();
}
if flying != was_flying {
self.send(S::PlayerAbilities(play::PlayerAbilitiesRequest {
flags: if flying { 2 } else { 0 },
}));
}
if started_gliding {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 6,
data: 0,
}));
}
self.remote_send_state(input);
Ok(())
}
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
/// and is invulnerable as the server says (bits 2, 4 and 1).
fn apply_remote_abilities(&mut self) {
let (Some(player), Some(a)) = (
self.player.as_mut(),
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
) else {
return;
};
player.invulnerable = a.flags & 1 != 0;
player.flying = a.flags & 2 != 0;
player.may_fly = a.flags & 4 != 0;
player.flying_speed = a.flying_speed;
}
/// The local player's share of every touching entity's
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
/// `EntitySelector.pushableBy` lets entities push only the local player,
/// so the server never sends these pushes. `doPush(player)` is
/// `player.push(entity)`.
fn remote_pushes(&self) -> Vec3 {
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
return Vec3::ZERO;
};
// LivingEntity.isPushable: alive, no spectator, off ladders.
let feet = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
if self.screen == Screen::Dead
|| p.spectator
|| self.block_in_tag("minecraft:climbable", &self.block_name(feet))
{
return Vec3::ZERO;
}
let bounds = p.bounds();
let teams = self.mob_teams();
let mut push = Vec3::ZERO;
for m in &self.mobs {
let spectator = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid))
.is_some_and(|t| t.game_mode == SPECTATOR);
// Bats never push; a spectator has no physics; passengers of the
// same vehicle never push each other.
if !m.alive()
|| spectator
|| m.species() == horizon_client::mobs::Species::Bat
|| self
.riding
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|| !m.bounds().intersects(bounds)
|| !horizon_client::scoreboard::pushable_by(
teams.of(Who::Mob(m.id)),
teams.of(Who::Player),
true,
)
{
continue;
}
// Entity.push(Entity), `this` the player.
let (mut xa, mut za) = (m.position.x - p.position.x, m.position.z - p.position.z);
let mut dd = xa.abs().max(za.abs());
if dd < f64::from(0.01_f32) {
continue;
}
dd = dd.sqrt();
xa /= dd;
za /= dd;
let pow = (1.0 / dd).min(1.0);
xa *= pow;
za *= pow;
xa *= f64::from(0.05_f32);
za *= f64::from(0.05_f32);
push = Vec3::new(push.x - xa, push.y, push.z - za);
}
// AbstractBoat.push(player) from the boat's own tick (its box grown
// by 0.2 sideways) and from the player's pushEntities (its box).
let player_team = teams.of(Who::Player);
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
let bb = b.bounds();
let times = usize::from(
bounds.intersects(bb.inflate(area))
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
) + usize::from(
bounds.intersects(bb)
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
);
if times > 0
&& super::boat_passengers::boat_pushes(bb, bounds, false)
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
{
push = push.add_vector(v.scale(times as f64));
}
}
push
}
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
/// `GuardianRenderer.getEntityToLookAt`: a guardian's eye follows its
/// synched attack target, else the camera.
fn tick_remote_guardian_eyes(&mut self, running: bool) {
use horizon_client::mobs::{Species, Who, guardian};
if !running {
return;
}
let camera = self.player.as_ref().map(|p| {
[
p.position.x,
p.position.y + f64::from(p.dimensions.eye_height),
p.position.z,
]
});
let looks: Vec<_> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| {
matches!(m.species(), Species::Guardian | Species::ElderGuardian) && m.alive()
})
.filter_map(|(i, m)| {
let look = match m.ai.target.filter(|_| m.extra.flag) {
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.eye_y(), t.position.z]),
_ => camera,
};
look.map(|look| (i, look))
})
.collect();
for (i, look) in looks {
guardian::look_at(&mut self.mobs[i], look);
}
}
fn tick_remote_entities(&mut self, running: bool) {
if running {
self.tick_text_displays();
}
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
// A frozen level ticks only the players, as below.
let (fluids, thundering): (Vec<_>, bool) = {
let w = self.mob_world_base(&[]);
let players = self.remote.as_ref().map(|r| &r.level_players);
let fluids = self
.mobs
.iter()
.map(|m| {
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
.then(|| w.fluids(m.bounds(), m.eye_y()))
})
.collect();
(fluids, w.thundering)
};
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
if let Some(fluids) = fluids {
m.fluids = fluids;
}
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = self.dim == super::Dim::Nether;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
.mobs
.iter()
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
{
let own = self
.remote
.as_ref()
.zip(self.eye_position())
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
self.mobs
.iter()
.map(|m| {
(
m.id as i32,
[m.position.x, m.position.y, m.position.z],
m.eye_y(),
)
})
.chain(own)
.collect()
} else {
Vec::new()
};
let Some(r) = self.remote.as_mut() else {
return;
};
let mut lerps = std::mem::take(&mut r.lerps);
let stepped = &mut r.stepped;
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut mob_fx = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
continue;
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
m.previous_head_yaw = m.head_yaw;
m.previous_head_pitch = m.head_pitch;
m.previous_limb_speed = m.limb_speed;
m.age += 1;
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::base_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Horse
| horizon_client::mobs::Species::Donkey
| horizon_client::mobs::Species::Mule
| horizon_client::mobs::Species::SkeletonHorse
| horizon_client::mobs::Species::ZombieHorse
) {
horizon_client::mobs::equine::tail_tick(m);
}
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Guardian
| horizon_client::mobs::Species::ElderGuardian
) {
horizon_client::mobs::guardian::client_tick(m);
}
// PolarBear.tick: the stand animation follows the synched flag.
if m.species() == horizon_client::mobs::Species::PolarBear {
horizon_client::mobs::polar_bear::client_tick(m);
}
// The particles these mobs' own ticks draw on the client side.
{
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
match m.species() {
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
S::Wither => wither::client_smoke(m, &mut mob_fx),
_ => {}
}
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
if !m.alive() {
m.death_time += 1;
}
// LivingEntity.updateSwingTime.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
// Entity.tick: the SteppedInterpolationHandler's step.
if let Some(at) = stepped
.get_mut(&(m.id as i32))
.and_then(|h| h.interpolate())
{
place_interpolated(m, at);
}
// LivingEntity.aiStep: the head's own interpolation.
if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32))
&& *steps > 0
{
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
*steps -= 1;
}
// Fox.tick: the head tilt and crouch follow the synched flags.
if m.species() == horizon_client::mobs::Species::Fox {
horizon_client::mobs::fox::client_tick(m);
}
// Cat.tick: the lying and relaxing amounts follow the synched flags.
if m.species() == horizon_client::mobs::Species::Cat {
m.anim.vo = m.anim.v;
horizon_client::mobs::tame::cat_amounts(m);
}
// Chicken.aiStep: the wings follow the server's on-ground flag.
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
}
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
if player {
// AvatarRenderer.getArmPose: the used item's BLOCK animation
// (an item with `blocks_attacks`) raises the arm.
let used = if m.extra.using_off_hand {
&m.extra.off_hand
} else {
&m.extra.main_hand
};
m.extra.blocking = m.extra.using_item > 0
&& used
.as_ref()
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
if m.extra.using_item > 0 {
if player {
let item = if m.extra.using_off_hand {
m.extra.off_hand.clone()
} else {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = m.eye_height();
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
m.extra.using_item as u32 - 1,
at,
eye,
m.head_pitch,
m.yaw,
));
}
}
m.extra.using_item += 1;
}
// LivingEntity.tick's tickHeadTurn: a mob's BodyRotationControl,
// a player's body following its walk and look.
if player {
remote_player_head_turn(m);
// LivingEntity.tick: the glide count, wings and swim amount.
m.tick_avatar_pose(m.fluids.in_water);
} else if m.alive() {
m.body_rotation_tick();
}
// Squid.aiStep follows LivingEntity's interpolation and head turn.
horizon_client::mobs::aquatic::squid_client_step(m);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
m.limb_speed += (target - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::post_tick(m);
}
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
match m.species() {
// WitherBoss.aiStep: the side heads turn toward their targets.
horizon_client::mobs::Species::Wither => {
horizon_client::mobs::wither::client_tick(m, |id| {
wither_targets
.iter()
.find(|t| t.0 == id)
.map(|t| (t.1, t.2))
});
}
// Hoglin and piglin `isConverting` outside the Nether.
horizon_client::mobs::Species::Hoglin
| horizon_client::mobs::Species::Piglin
| horizon_client::mobs::Species::PiglinBrute => {
horizon_client::mobs::piglin::client_converting(m, nether);
}
horizon_client::mobs::Species::Panda => {
horizon_client::mobs::panda::client_tick(m, thundering);
}
_ => {}
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
o.previous = o.position;
o.age += 1;
if let Some(l) = lerps.get_mut(&(o.id as i32))
&& l.steps > 0
{
let n = f64::from(l.steps);
o.position = Vec3::new(
o.position.x + (l.target[0] - o.position.x) / n,
o.position.y + (l.target[1] - o.position.y) / n,
o.position.z + (l.target[2] - o.position.z) / n,
);
l.steps -= 1;
}
}
// ItemEntity.tick runs on a client too: gravity, collisions and
// friction move the item between the server's updates (every 20
// ticks), and only the server removes it.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
let _ = self.handle_mob_events(mob_fx);
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
.as_ref()
.and_then(|d| lerps.get_mut(&(d.id as i32)))
.filter(|l| l.steps > 0)
.map(|l| {
let steps = l.steps;
l.steps -= 1;
(l.target, l.rotation.map(|r| r.0), steps)
});
self.tick_remote_dragon(dragon_lerp);
// Boats, minecarts, armor stands, projectiles and the rest
// interpolate like mobs.
let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) {
Some(l) if l.steps > 0 => {
let n = f64::from(l.steps);
l.steps -= 1;
Vec3::new(
at.x + (l.target[0] - at.x) / n,
at.y + (l.target[1] - at.y) / n,
at.z + (l.target[2] - at.z) / n,
)
}
_ => at,
};
self.tick_remote_objects(&mut step);
self.tick_remote_projectiles(&mut step);
// LightningBolt.tick on a client, and the sky flash fading.
self.flash = self.flash.saturating_sub(1);
self.tick_bolts();
}
lerps.retain(|_, l| l.steps > 0);
if let Some(r) = self.remote.as_mut() {
for (id, l) in lerps {
r.lerps.entry(id).or_insert(l);
}
}
}
/// `Consumable.onConsumeTick` for another player using a consumable: the
/// crumbs at their mouth every 4 ticks once 21.875% of its use has passed
/// (`shouldEmitParticlesAndSounds`; the sounds come from the server).
fn remote_player_eating(
&mut self,
item: &str,
ticks: u32,
at: [f64; 3],
eye: f64,
pitch: f32,
yaw: f32,
) {
let Some(consumable) = self
.res
.items()
.get(item)
.and_then(|i| i.consumable.as_ref())
.filter(|c| c.particles)
else {
return;
};
let duration = consumable.ticks().max(0) as u32;
let remaining = duration - ticks.min(duration);
if !consumable.should_emit(remaining as i32) {
return;
}
let options = self.item_particle(&ItemStack::new(item, 1));
let mut spawned = Vec::new();
horizon_client::particles::item_use_particles(
&mut self.world.random,
pitch,
yaw,
at,
at[1] + eye,
5,
|p, v| spawned.push((p, v)),
);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// LocalPlayer.sendPosition plus input, sprint and held-slot updates.
fn remote_send_state(&mut self, input: u8) {
let Some(p) = self.player.as_ref() else {
return;
};
let (pos, on_ground, collision, sprinting) = (
[p.position.x, p.position.y, p.position.z],
p.ground.on_ground,
p.collision.horizontal,
p.sprinting,
);
let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected);
let riding = self.riding.is_some();
let vehicle = self.remote_vehicle_packets();
let Some(r) = self.remote.as_mut() else {
return;
};
let mut out = Vec::new();
if r.carried != Some(selected) {
r.carried = Some(selected);
out.push(S::SetCarriedItem(play::SetCarriedItem {
slot: selected as i16,
}));
}
if input != r.last_input {
r.last_input = input;
out.push(S::PlayerInput(play::PlayerInput { flags: input }));
}
if sprinting != r.was_sprinting {
r.was_sprinting = sprinting;
out.push(S::PlayerCommand(play::PlayerCommand {
id: r.entity_id,
action: if sprinting { 1 } else { 2 },
data: 0,
}));
}
if riding {
// LocalPlayer.tick: a passenger sends its look, then the vehicle
// it controls.
out.push(S::MovePlayerRot(play::MovePlayerRot {
yaw,
pitch,
flags: u8::from(on_ground) | u8::from(collision) << 1,
}));
out.extend(vehicle);
} else if let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
{
out.push(packet);
}
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
// `synced[36]` is the off hand.
let mut creative_slots = Vec::new();
if self.creative {
for i in 0..37 {
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
if *stack != r.synced[i] {
r.synced[i] = stack.clone();
creative_slots.push(i);
}
}
}
for packet in out {
self.send(packet);
}
for i in creative_slots {
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
let slot = match i {
0..9 => 36 + i,
36 => 45,
_ => i,
} as i16;
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item,
}));
}
}
/// Inventory as the server last described it.
fn remote_synced(&mut self) {
let mut inventory = self.inventory.clone();
inventory.push(self.offhand.clone());
if let Some(r) = self.remote.as_mut() {
r.synced = inventory;
}
}
/// `MultiPlayerGameMode.stopDestroyBlock`: aborts the break and starts
/// the attack strength again.
pub(super) fn stop_remote_mining(&mut self) {
if let Some((pos, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
self.send(S::PlayerAction(play::PlayerAction {
action: 2,
pos,
direction: 0,
sequence: 0,
}));
self.reset_attack_strength();
}
}
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
fn remote_mining(&mut self, start: bool) {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.stop_remote_mining();
self.mining = None;
return;
}
let Some(r) = self.remote.as_mut() else {
return;
};
if !start && r.destroy_delay > 0 {
r.destroy_delay -= 1;
return;
}
let Some(hit) = self.pick() else {
self.mining = None;
return;
};
let face = face_direction(hit.face);
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if self.res.facts().is_air(state) {
return;
}
if self.creative {
let seq = self.next_sequence();
self.predict_break(hit.pos, state, seq);
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
direction: face,
sequence: seq,
}));
if let Some(r) = self.remote.as_mut() {
r.destroy_delay = 5;
}
return;
}
let facts = self.res.facts().get(state).clone();
// The server's own getDestroyProgress: under water, off the ground,
// with haste, fatigue and efficiency, so the client stops digging
// when the server is done too (#1254).
let progress = if facts.hardness == 0.0 {
1.0
} else {
self.destroy_progress(hit.pos)
};
// sameDestroyTarget: the same block and still the item the break began with.
let held = self.main_hand_key();
let same = self.remote.as_ref().is_some_and(|r| {
r.destroying.is_some_and(|(p, _, _)| p == hit.pos) && r.destroying_item == held
});
if start && same {
return;
}
if !same {
if let Some((old, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
self.send(S::PlayerAction(play::PlayerAction {
action: 2,
pos: old,
direction: face,
sequence: 0,
}));
}
let seq = self.next_sequence();
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
direction: face,
sequence: seq,
}));
if progress >= 1.0 {
// startDestroyBlock: instant survival breaks have no destroy delay.
self.predict_break(hit.pos, state, seq);
return;
}
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
r.destroying_item = held;
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
ticks: 0,
});
return;
}
let Some((pos, last_face, done)) = self.remote.as_ref().and_then(|r| r.destroying) else {
return;
};
let done = done + progress;
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
self.send(S::PlayerAction(play::PlayerAction {
action: 3,
pos,
direction: face,
sequence: seq,
}));
if let Some(r) = self.remote.as_mut() {
r.destroying = None;
r.destroy_delay = 5;
}
self.mining = None;
} else {
if last_face != face {
self.send(S::PlayerAction(play::PlayerAction {
action: 1,
pos,
direction: face,
sequence: 0,
}));
}
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((pos, face, done));
}
self.mining = Some(Mining {
pos,
progress: done,
ticks,
});
}
}
/// MultiPlayerGameMode.destroyBlock: the predicted break.
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
// BlockStatePredictionHandler.retainKnownServerState.
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|p| p.0 = sequence)
.or_insert((sequence, state.0 as i32));
}
// Block.playerWillDestroy's destroy event (particles and the break
// sound), which BaseFireBlock skips: the server's extinguish sound
// is all a player hears.
let facts = self.res.facts().get(state);
if !facts.has_class("BaseFireBlock") {
let s = facts.sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(pos, state);
}
// The server's block update follows (waterlogged blocks leave water).
let air = self.world.air;
if self.world.set(pos[0], pos[1], pos[2], air).is_ok() {
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
}
self.mining = None;
}
/// Minecraft.startUseItem: use one crosshair target, then the item itself.
fn remote_use(&mut self) {
// Minecraft.startUseItem returns before resetting rightClickDelay
// while a survival block break is active.
if self.remote.as_ref().is_some_and(|r| r.destroying.is_some()) {
return;
}
self.use_delay = 4;
// Resolve the crosshair once. A passing entity interaction keeps
// that target and may only fall back to using the item itself.
let entity = self.entity_targeted();
horizon_client::interaction_input::use_on_target(&mut RemoteUseWorld(self), entity);
}
/// `Minecraft.startUseItem`'s entity turn for `hand`: Interact goes to
/// the server, and whether the click ends there is what this client's
/// copy of the entity answers (`MultiPlayerGameMode.interact`).
fn remote_use_entity(&mut self, hand: Hand) -> bool {
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
// A boat, minecart, item frame, armor stand or painting nearer than
// any mob: the server uses it (AbstractBoat.interact boards it).
if let Some(hit) = self.remote_object_hit(false)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// The main hand's turn takes it; there is no off hand turn.
if hand == Hand::Off {
return false;
}
self.send(S::Interact(play::Interact {
id: hit.id,
hand: 0,
location: hit.location,
secondary: sneaking,
}));
self.swing_arm();
return true;
}
if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
let id = self.mobs[i].id as i32;
self.send(S::Interact(play::Interact {
id,
hand: hand_id(hand),
location: [0.0, 1.0, 0.0],
secondary: sneaking,
}));
// Player.interactOn: another player passes, and so does a mob
// the held item means nothing to: the item is used instead.
let player = self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&id));
if !player && self.remote_mob_takes(i, hand) {
self.with_hand(hand, Game::swing_arm);
return true;
}
}
false
}
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
/// (the block reacts, or a block item is placed).
fn remote_use_block(&mut self, hand: Hand) -> bool {
let held = self.with_hand(hand, |g| g.held().cloned());
let held_block = held
.as_ref()
.and_then(|h| self.res.items().get(&h.item))
.is_some_and(|i| i.block.is_some());
if let Some(hit) = self.pick() {
let (reacts, changes) = if hand == Hand::Main {
self.remote_block_reacts(&hit, held.as_ref())
} else {
(false, DryUse::default())
};
let seq = self.next_sequence();
let cursor = [
(hit.point[0] - f64::from(hit.pos[0])) as f32,
(hit.point[1] - f64::from(hit.pos[1])) as f32,
(hit.point[2] - f64::from(hit.pos[2])) as f32,
];
self.send(S::UseItemOn(play::UseItemOn {
hand: hand_id(hand),
pos: hit.pos,
face: i32::from(face_direction(hit.face)),
cursor,
inside: hit.face == [0; 3],
world_border: false,
sequence: seq,
}));
// Only a predicted placement (predict_place) marks a cell whose
// resend plays a place sound: an empty hand or a non-block item
// places nothing (BlockItem.place is where the sound comes from).
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
self.with_hand(hand, Game::swing_arm);
return true;
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
// server brushes and the dust is this client's own.
self.with_hand(hand, |g| {
if g.using.is_none() {
g.using = Some((g.held_slot(), 0));
}
});
return true;
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
// Item.useOn succeeded here (InteractionResult.SUCCESS):
// this hand swings and the click ends, so the other hand
// (a shield, a torch) is never tried.
self.with_hand(hand, Game::swing_arm);
return true;
}
if held_block {
let placed = if reacts {
None
} else {
held.as_ref()
.and_then(|stack| self.remote_can_place(&hit, stack))
};
if let Some(placed) = &placed {
self.predict_place(placed, seq);
self.with_hand(hand, Game::consume_held);
} else if let Some(r) = self.remote.as_mut() {
// BlockItem.place plays its sound only once the block is set: the
// server's resend of the target it refused is no placement.
r.placing = None;
}
if reacts || placed.is_some() {
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
// stop this click without trying the item or the other hand.
return true;
}
return reacts;
}
false
}
/// `Item.useOn` on this client's level for the items whose effects are
/// the clicker's own (`Level.levelEvent(player, ...)` and
/// `playSound(player, ...)`: the server sends them to the others only):
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
/// block (stripping, scraping, removing wax, tilling, making a path).
/// Whether the item's use succeeded, which ends the click.
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
f64::from(hit.pos[2]) + 0.5,
];
if &*stack.item == "minecraft:honeycomb" {
// HoneycombItem.useOn.
let registry = self.pack.assets.registry;
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let waxes = registry
.state(clicked)
.and_then(|old| self.res.transformers().waxed(registry, old))
.is_some();
if waxes {
self.level_event_at(3003, hit.pos, 0);
self.audio
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
}
waxes
} else if let Some((_, sound, _, _, particle)) =
self.block_transform(stack, hit.pos, hit.face)
{
self.transform_particle(&particle, hit.pos);
self.audio.play(&sound, Some(at), 1.0, 1.0);
true
} else {
false
}
}
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
/// use changed on this client's level at once, each kept as predicted
/// under the use's sequence until the server acknowledges it (a use the
/// server refused goes back). Only the blocks a hand opens or flips: a
/// wooden or copper door, trapdoor or fence gate, a lever or a button;
/// whatever else the use changes waits for the server.
fn predict_use(&mut self, (changes, sounds): &DryUse, seq: i32) {
let facts = self.res.facts();
let predicted = |state: BlockStateId| hand_operated(&facts.get(state).name);
if changes.is_empty()
|| !changes
.iter()
.all(|(p, s)| predicted(*s) && predicted(self.world.block(p.x, p.y, p.z)))
{
return;
}
self.predict_blocks(changes, seq);
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
// server sends it to the others only.
for (pos, sound, volume, pitch) in sounds {
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 0.5,
f64::from(pos.z) + 0.5,
];
let name = if sound.contains(':') {
sound.to_string()
} else {
format!("minecraft:{sound}")
};
self.audio.play(&name, Some(at), *volume, *pitch);
}
}
/// Whether the block hit takes a main-hand right click with `held`
/// (`BlockState.useItemOn`, then `useWithoutItem`): a door, a button, a
/// container, and the blocks it changed. Vanilla's client runs it on its
/// own level first; this runs it on a copy, and `predict_use` keeps the
/// changes it predicts.
fn remote_block_reacts(
&mut self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> (bool, DryUse) {
// Player.isSecondaryUseActive with something in either hand.
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (false, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
// no UseItem follows (the server does the harvest, see `use_beehive`).
if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle"))
&& self
.hive_state(hit.pos)
.is_some_and(|(honey, _)| honey == Some(5))
{
return (true, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
[0, 1, 0] => Direction::Up,
[0, -1, 0] => Direction::Down,
[0, 0, -1] => Direction::North,
[0, 0, 1] => Direction::South,
[-1, 0, 0] => Direction::West,
_ => Direction::East,
};
let player = self.use_context();
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
let reacts = b
.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
)
.consumes_action();
(reacts, (level.changed.into_iter().collect(), level.sounds))
})
}
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
/// level at once, so the next click of a fast bridge aims at them rather
/// than past them (the server would refuse it). Its place sound is the
/// placer's own (`playSound(player, ...)`: the server sends it to the
/// others only).
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
// set_block plays the place sound of the block at `placing`.
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
}
self.predict_blocks(changes, seq);
}
/// The blocks a use changed, set on this client's level and kept as
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
}
self.set_block(pos, state.0 as i32);
}
}
/// Run BlockItem.place on a disposable level: where it placed and the
/// blocks it set. The real level supplies collision shapes and
/// building-blocking entities; random draws, scheduled ticks and events
/// stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
if !self.may_build() {
return None;
}
let ctx = horizon_world::level::PlaceContext {
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face: horizon_core::geometry::Direction::ALL[face_direction(hit.face) as usize],
hit: Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
inside: hit.face == [0; 3],
yaw: self.yaw,
pitch: self.pitch,
sneaking: self.keys.contains(&KeyCode::ShiftLeft),
item: stack.item.to_string(),
can_use_game_master_blocks: self.can_use_game_master_blocks(),
};
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
match b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx,
) {
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
Some((pos, level.changed.into_iter().collect()))
}
horizon_world::level::PlaceOutcome::Failed => None,
}
})
}
/// MultiPlayerGameMode.useItem for `hand`, if it holds something: whether
/// the click ends there, as far as this client can tell (`item_has_use`).
fn remote_use_item(&mut self, hand: Hand) -> bool {
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
return false;
};
let seq = self.next_sequence();
self.send(S::UseItem(play::UseItem {
hand: hand_id(hand),
sequence: seq,
yaw: self.yaw,
pitch: self.pitch,
}));
let Some(info) = self.res.items().get(&held.item).cloned() else {
return false;
};
// MultiPlayerGameMode.useItem's prediction: an item cooling down
// passes (its packet still goes).
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
if self.cooldowns.is_on_cooldown(&group) {
return false;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
// no cooldown or use animation starts.
let eating = self.with_hand(hand, |g| g.start_eating(&info));
if info.food.is_some() && !eating {
return false;
}
// It runs the use here too: ItemStack.use starts the `use_cooldown`
// of an item with no use time, and the server's
// ClientboundCooldownPacket confirms it; one held in use starts it at
// finishUsingItem (remote_complete_using).
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
self.apply_use_cooldown(&held);
}
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
return true;
}
self.with_hand(hand, |g| g.remote_start_held_use(&held));
item_has_use(&info)
}
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
/// item held in use until the button lets go starts being used here, so
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
/// the server ends the use: a bow or a trident is let go, a crossbow's
/// charge ends, a shield is lowered, a spyglass stops scoping. Their
/// sounds, loaded arrows and thrown tridents come from the server.
fn remote_start_held_use(&mut self, held: &ItemStack) {
if self.using.is_some() {
return;
}
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
let start = match &*held.item {
// BundleItem.use: always starts; the server drops the contents.
_ if bundle => true,
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
// CrossbowItem.use: charging when not charged and with ammunition;
// a charged one shoots on the server.
"minecraft:crossbow" => {
held.data().is_none_or(|d| d.charged.is_empty())
&& (self.creative || self.projectile_slot("minecraft:crossbow").is_some())
}
// TridentItem.use: not about to break, Riptide only in water or rain.
"minecraft:trident" => {
self.use_trident(held);
return;
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// Item.use: a kinetic weapon starts being used.
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
// ShieldItem.use: anything that blocks attacks, off cooldown.
_ => {
self.raise_shield(self.held_slot());
return;
}
};
if start {
self.using = Some((self.held_slot(), 0));
}
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
fn held_until_released(&self, stack: &ItemStack) -> bool {
matches!(
&*stack.item,
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|| self.blocks_attacks(stack).is_some()
}
/// The rest of Minecraft.startUseItem once the main hand's target passed:
/// the main hand's item, then the off hand's turn (its block, its item).
/// The server does each packet's part with that packet's hand only.
fn remote_use_hands(&mut self) {
if self.remote_use_item(Hand::Main) {
return;
}
// The off hand's turn: the entity again, or the block.
let off_target = if self.entity_targeted() {
self.remote_use_entity(Hand::Off)
} else {
self.remote_use_block(Hand::Off)
};
if off_target {
return;
}
self.remote_use_item(Hand::Off);
}
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a consumable's eating animation and
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
/// stack, its hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
return;
};
let stack = self.stack_in(slot).cloned();
// The use stops when the hand no longer holds the item it began with
// (ItemStack.isSameItem).
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
if let Some(r) = self.remote.as_mut() {
if ticks == 0 {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// BrushItem.onUseTick: with no block aimed the use ends (the server
// ends it too); a stroke shows its dust. Its sounds are the server's.
if stack
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:brush")
{
let elapsed = ticks + 1;
let hit = self.pick().filter(|_| in_hand);
if let Some(hit) = &hit
&& elapsed % 10 == 5
{
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
if hit.is_none() && in_hand {
self.send(S::PlayerAction(play::PlayerAction {
action: 6,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
}
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
return;
}
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
if stack
.as_ref()
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
{
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
return;
}
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
let consumable = stack
.as_ref()
.and_then(|s| self.res.items().get(&s.item))
.and_then(|i| i.consumable.clone());
let (Some(stack), Some(consumable)) = (stack, consumable) else {
self.using = None;
return;
};
// Button release belongs to the next handleKeybinds phase, which
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
// quick press/release between ticks.
if !in_hand {
self.using = None;
return;
}
// Consumable.startConsuming eats one with no use time at once, without
// a use (nor a USE_ITEM_COMPLETE).
if consumable.ticks() <= 0 {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
if consumable.should_emit(remaining) {
// `emit_consume` plays the eat sound and spawns the bites (when the
// item has consume particles), as `tick_used_item` does on the
// player's own server.
self.emit_consume(&consumable, &stack, 5);
}
self.using = Some((slot, ticks + 1));
}
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
/// which runs on the client too (Consumable.onConsume calls its
/// ConsumableListeners on both sides): the client's FoodData eats at once,
/// so a held right click finds `Player.canEat` false instead of starting
/// another bite before the server's ClientboundSetHealth arrives.
fn remote_complete_using(&mut self) {
let Some((slot, _)) = self.using.take() else {
return;
};
let Some(stack) = self.stack_in(slot).cloned() else {
return;
};
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return;
};
if info.consumable.as_ref().is_some_and(|c| c.particles) {
self.eat_particles(&stack, 16);
}
if let Some((nutrition, saturation)) = info.food
&& let Some(r) = self.remote.as_mut()
{
// FoodData.eat(nutrition, saturation), as finish_consuming does;
// the next ClientboundSetHealth replaces it.
let (_, food, sat) = &mut r.health;
*food = (*food + nutrition as i32).clamp(0, 20);
*sat = (*sat + saturation).clamp(0.0, *food as f32);
self.apply_remote_health();
}
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
}
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
use horizon_client::interaction_input::Button;
let input = match button {
MouseButton::Left => {
self.toggles
.button(2, pressed, false, self.settings.toggle_attack);
self.left = self.toggles.is_down(2);
Some(Button::Attack)
}
MouseButton::Right => {
self.toggles
.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
Some(Button::Use)
}
MouseButton::Middle => Some(Button::Pick),
_ => None,
};
if pressed
&& let Some(button) = input
&& let Some(remote) = self.remote.as_mut()
{
remote.input.press(button);
}
Ok(())
}
pub(super) fn request_remote_mining_stop(&mut self) {
if let Some(r) = self.remote.as_mut() {
r.stop_mining = true;
}
}
fn remote_handle_input(&mut self) {
let Some(r) = self.remote.as_mut() else {
return;
};
let mut input = std::mem::take(&mut r.input);
let stop = std::mem::take(&mut r.stop_mining);
let ready = r.loaded_sent && self.screen == Screen::Playing && self.sleeping.is_none();
if stop || !ready {
self.stop_remote_mining();
}
if !ready {
return;
}
// MultiPlayerGameMode.tick syncs the carried slot before keybinds.
let carried_changed = self.remote.as_mut().is_some_and(|r| {
if r.carried == Some(self.selected) {
false
} else {
r.carried = Some(self.selected);
true
}
});
if carried_changed {
self.send(S::SetCarriedItem(play::SetCarriedItem {
slot: self.selected as i16,
}));
}
let (attack, use_item) = (self.left, self.right);
input.tick(&mut RemoteInputWorld(self), attack, use_item);
// handleKeybinds' keybind polls: the queued presses act now, in tick
// order, so no packet goes out between CLIENT_TICK_END and a pong.
let pending = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.pending_keys))
.unwrap_or_default();
for key in pending {
match key {
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
KeyCode::KeyQ if !self.spectator() => {
// Minecraft.hasControlDown: the physical keys, so a toggled
// sprint on Left Control does not make Q drop the stack.
let all = self.has_control_down();
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
}
_ => {}
}
}
if self.clicks.miss_time > 0 {
self.clicks.miss_time -= 1;
}
if let Some(r) = self.remote.as_mut() {
r.input = input;
}
}
fn remote_start_attack(&mut self) -> bool {
if self.clicks.miss_time > 0 {
return false;
}
if self.spectator() {
return true;
}
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
// stabs, this client swings, plays the sound and starts its charge
// again.
if let Some(kinetic) = self
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
{
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
return false;
}
// ServerboundPlayerActionPacket.Action.STAB.
self.send(S::PlayerAction(play::PlayerAction {
action: 8,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
self.hand_strength.reset_attack();
self.swing_hand(Hand::Main, kinetic.attack_animation());
self.play_at_feet(kinetic.attack_sound);
return true;
}
let mut instant = false;
// A ghast's fireball or a wind charge nearer than any mob: the
// server turns it (game/deflect.rs).
if let Some((i, distance)) = self.redirectable_hit()
&& self.mob_hit().is_none_or(|(_, d)| distance <= d)
&& self.before_block(distance)
&& self.creatures.projectiles[i].id != 0
{
self.send(S::Attack(play::Attack {
id: self.creatures.projectiles[i].id as i32,
}));
self.reset_attack_strength();
} else if let Some(hit) = self.remote_object_hit(true)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
// a painting broken or an EndCrystal hurtServer: the server decides.
self.send(S::Attack(play::Attack { id: hit.id }));
self.reset_attack_strength();
} else if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
self.send(S::Attack(play::Attack {
id: self.mobs[i].id as i32,
}));
self.reset_attack_strength();
} else if !self.entity_targeted()
&& let Some(hit) = self.pick()
{
self.remote_mining(true);
instant = self
.res
.facts()
.is_air(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
} else {
if !self.creative {
self.clicks.miss_time = 10;
}
self.reset_attack_strength();
}
self.swing_arm();
self.send(S::Punch(Empty));
instant
}
fn remote_continue_attack(&mut self, down: bool) {
if !down {
self.clicks.miss_time = 0;
}
// A piercing weapon neither breaks blocks nor swings on its own.
if self.clicks.miss_time > 0
|| self.using.is_some()
|| self
.held()
.is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some())
{
return;
}
let entity = self.entity_targeted();
if down
&& !entity
&& let Some(hit) = self.pick()
{
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if !self.res.facts().is_air(state) {
self.remote_mining(false);
self.swing_arm();
self.send(S::Punch(Empty));
} else {
if let Some(r) = self.remote.as_mut() {
r.destroying = None;
}
self.mining = None;
}
} else {
self.stop_remote_mining();
self.mining = None;
}
}
/// Keys with a multiplayer meaning; returns true when consumed.
pub(super) fn remote_key(&mut self, key: KeyCode) -> Result<bool> {
if self.screen != Screen::Playing {
if (key == KeyCode::KeyE || key == KeyCode::Escape) && self.screen == Screen::Inventory
{
self.remote_close_container();
self.screen = Screen::Playing;
self.capture(true);
return Ok(true);
}
return Ok(false);
}
match key {
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
// ChatScreen, over the server's command tree.
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
Ok(true)
}
KeyCode::KeyQ => {
if self.spectator() {
return Ok(true);
}
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
// MultiPlayerGameMode.isServerControlledInventory: riding a
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.recipe_ui.reset_screen();
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
self.screen = Screen::Inventory;
}
self.craft_size = 2;
self.capture(false);
Ok(true)
}
_ => Ok(false),
}
}
/// ClientPacketListener.sendChat / sendCommand (unsigned). The Chat
/// option's Hidden and System levels keep chat out and Hidden keeps the
/// commands out (ChatAbilities).
pub(super) fn send_chat(&mut self, message: &str) {
let message = message.trim();
if message.is_empty() || !self.chat_abilities().allows(message) {
return;
}
if let Some(command) = message.strip_prefix('/') {
self.send(S::ChatCommand(play::ChatCommand {
command: command.to_owned(),
}));
} else {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
self.send(S::Chat(play::Chat {
message: message.chars().take(256).collect(),
timestamp,
salt: 0,
signature: None,
last_seen: play::LastSeenUpdate {
offset: 0,
acknowledged: [0; 3],
checksum: 0,
},
}));
}
}
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
/// the action; swing only if a nonempty stack was removed.
fn remote_drop(&mut self, all: bool) {
let dropped =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
}
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
/// sends both slots back; nothing is predicted.
pub(super) fn remote_swap_hands(&mut self) {
self.send(S::PlayerAction(play::PlayerAction {
action: 7,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
}
/// A container click: the server answers with the authoritative slots.
pub(super) fn remote_click(&mut self, right: bool) {
let Some(size) = self
.renderer
.as_ref()
.map(|r| [r.config.width, r.config.height])
else {
return;
};
let batch = HudBatch::new(size);
let Some(internal) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return;
};
let input = if self.has_shift_down() {
Input::QuickMove
} else {
Input::Pickup
};
self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]);
}
/// The server's open menu: its window id and menu type (0, "inventory"
/// when none is open).
pub(super) fn remote_menu(&self) -> (i32, String) {
match self.remote.as_ref().and_then(|r| r.menu.clone()) {
Some((id, name)) => (id, name),
None => (0, "inventory".to_owned()),
}
}
/// `MultiPlayerGameMode.handleContainerInput` for each click of `ops` on
/// the server's open menu (a quick craft drag is several): the verified
/// menu engine predicts them on this client's copy, then each goes out as
/// a `ContainerClick` with the slots it changed and the carried stack
/// after it; the server corrects anything it sees differently. A menu the
/// engine doesn't model sends the clicks unpredicted. False when a click's
/// slot isn't one of the menu's.
pub(super) fn remote_menu_ops(&mut self, ops: &[(Option<usize>, i32, Input)]) -> bool {
// CrafterScreen.slotClicked: a slot toggle goes out before the click.
if let [(Some(slot), button, input)] = ops {
self.crafter_slot_clicked(*slot, *button, *input);
}
let (container, menu) = self.remote_menu();
let mut clicks = Vec::with_capacity(ops.len());
for &(slot, button, input) in ops {
let slot = match slot {
None => -999,
Some(internal) => match Self::menu_slot_index(&menu, internal) {
Some(s) => s as i16,
None => return false,
},
};
clicks.push((slot, button, input));
}
// Unpredicted: nothing changed, the carried stack as it is.
let mut sent: Vec<Predicted> = clicks
.iter()
.map(|&(slot, button, input)| (slot, button, input, Vec::new(), self.carried.clone()))
.collect();
// The engine's slot indices are the menu's own, as on the network.
self.menu_session(|m, env, _| {
let mut predicted = Vec::with_capacity(clicks.len());
for &(slot, button, input) in &clicks {
let before = menu_items(m);
if m.click(env, i32::from(slot), button, input).is_err() {
// The server throws on it too: nothing is predicted.
return Some(false);
}
let changed = changed_slots(&before, m);
predicted.push((slot, button, input, changed, m.carried.clone()));
}
sent = predicted;
Some(true)
});
self.send_clicks(container, sent);
true
}
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
/// a predicted QUICK_MOVE for every slot the engine moves, each sent.
pub(super) fn remote_quick_move_all(&mut self, slot: usize, moved: &ItemStack) {
let (container, _) = self.remote_menu();
let mut sent: Vec<Predicted> = Vec::new();
self.menu_session(|m, env, to_menu| {
let clicked = to_menu(slot)?;
let mut before = menu_items(m);
let mut each = |m: &horizon_client::menu::Menu, t: usize| {
let changed = changed_slots(&before, m);
sent.push((t as i16, 0, Input::QuickMove, changed, m.carried.clone()));
before = menu_items(m);
};
Some(
m.quick_move_all_each(env, clicked, moved, &mut each)
.is_ok(),
)
});
self.send_clicks(container, sent);
}
fn send_clicks(&mut self, container: i32, clicks: Vec<Predicted>) {
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
let hashed = |g: &Self, s: &Option<ItemStack>| g.to_network(s).map(|s| Hashed::of(&s));
for (slot, button, input, changed, carried) in clicks {
let changed = changed.iter().map(|(i, s)| (*i, hashed(self, s))).collect();
let carried = hashed(self, &carried);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot,
button: button as i8,
input: input_ordinal(input),
changed,
carried,
}));
}
}
/// The server's open menu is the inventory or a chest-like container
/// (chests, barrels, ender chests: `generic_9xN`; shulker boxes).
pub(super) fn remote_storage_open(&self) -> bool {
match self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
None => true,
Some((_, name)) => {
name == "inventory"
|| name.starts_with("minecraft:generic_9x")
|| name == "minecraft:shulker_box"
}
}
}
/// A click outside a server's window (slot -999): predicted like
/// `MultiPlayerGameMode.handleContainerInput` (the carried stack after
/// `doClick`; the server drops the stack and swings the hand).
pub(super) fn remote_throw(&mut self, right: bool, input: horizon_client::menu::Input) {
use horizon_client::menu::Input;
let container = self
.remote
.as_ref()
.and_then(|r| r.menu.as_ref())
.map_or(0, |m| m.0);
if input == Input::Pickup
&& let Some(c) = self.carried.as_mut()
{
if right && c.count > 1 {
c.count -= 1;
} else {
self.carried = None;
}
self.swing_arm();
}
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot: -999,
button: i8::from(right),
input: if input == Input::Throw { 4 } else { 0 },
changed: Vec::new(),
carried,
}));
}
/// `CreativeModeInventoryScreen.slotClicked` outside the window: the server knows nothing of
/// a stack picked from the creative screen, so the carried stack (an item tab's right click:
/// one of it) is dropped through `handleCreativeModeItemDrop`, a SetCreativeModeSlot to slot
/// -1, and the hand swings as `Player.drop` predicts.
pub(super) fn remote_creative_drop(&mut self, right: bool) {
let one = right && !self.creative_inventory_open();
let Some(carried) = self.carried.as_mut() else {
return;
};
let thrown = if one && carried.count > 1 {
carried.count -= 1;
ItemStack {
count: 1,
..carried.clone()
}
} else {
self.carried.take().expect("carried")
};
if !self.creative {
return;
}
self.swing_arm();
let item = self.to_network(&Some(thrown));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: -1,
item,
}));
}
pub(super) fn remote_close_container(&mut self) {
let id = self
.remote
.as_ref()
.and_then(|r| r.menu.as_ref().map(|m| m.0))
.unwrap_or(0);
self.send(S::ContainerClose(play::ContainerClose { container: id }));
self.remote_close_local();
}
fn remote_close_local(&mut self) {
self.recipe_ui.reset_screen();
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
}
self.mount_menu_reset();
// The client's menu has no level access: what it holds is the server's.
self.station = None;
// AbstractContainerMenu.removed: the server gives the grid and the
// carried stack back to the inventory and syncs it; the copies here
// are stale the moment the window closes.
self.craft_grid.fill(None);
self.carried = None;
self.craft_size = 2;
if let Some(key) = self.container.take() {
// Server containers stay renderable but lose their copied slots.
if let Some(entity) = self.world.block_entities.get_mut(&key) {
for s in entity.slots_mut() {
*s = None;
}
}
}
}
/// A server's trading window (MerchantScreen over a ClientSideMerchant).
fn remote_open_merchant(&mut self, container: i32, title: String) {
if let Some(r) = self.remote.as_mut() {
r.menu = Some((container, "minecraft:merchant".to_owned()));
}
let session = horizon_client::merchant::Session::new(Vec::new(), true);
self.open_remote_merchant(session, (!title.is_empty()).then_some(title));
}
/// ClientboundMerchantOffersPacket.
fn remote_merchant_offers(&mut self, p: play::MerchantOffers) {
if self.open_menu_name(p.container) != "minecraft:merchant" {
return;
}
let cost = |g: &Self, c: &play::NetCost| {
g.to_stack(&Some(c.clone().into()))
.map(|stack| horizon_client::merchant::ItemCost { stack })
};
let offers: Vec<_> = p
.offers
.iter()
.filter_map(|o| {
let mut offer = horizon_client::merchant::MerchantOffer {
base_cost_a: cost(self, &o.cost_a)?,
cost_b: match &o.cost_b {
Some(b) => Some(cost(self, b)?),
None => None,
},
result: self.to_stack(&Some(o.result.clone()))?,
uses: o.uses,
max_uses: o.max_uses,
reward_exp: true,
special_price_diff: o.special_price,
demand: o.demand,
price_multiplier: o.price_multiplier,
xp: o.xp,
};
// setToOutOfStock.
if o.out_of_stock {
offer.uses = offer.max_uses;
}
Some(offer)
})
.collect();
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session.offers = offers;
m.session.level = p.level;
m.session.xp = p.xp;
m.session.villager = p.show_progress;
}
}
/// ClientboundOpenScreenPacket for the menus Horizon draws.
fn remote_open_screen(&mut self, p: play::OpenScreen) {
// A new RecipeBookComponent owns fresh ghost slots and an overlay,
// even when the server replaces a menu without a close packet.
self.recipe_ui.reset_screen();
let menu = self
.remote
.as_ref()
.and_then(|r| {
r.builtin
.get("minecraft:menu")?
.name(p.menu)
.map(str::to_owned)
})
.unwrap_or_default();
let target = self
.remote
.as_ref()
.and_then(|r| r.use_target)
.map(|t| (t[0], t[1], t[2]));
if menu == "minecraft:lectern" {
// LecternScreen: the book and page come in the menu's content and data.
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, menu));
}
self.remote_open_lectern(target.unwrap_or_default());
return;
}
if let Some(kind) = Self::remote_station_kind(&menu) {
let title = menu_title(&p.title);
let pos = target.map_or([0, 0, 0], |t| [t.0, t.1, t.2]);
self.remote_open_station(p.container, menu, kind, title, pos);
return;
}
if menu == "minecraft:merchant" {
// A trading window: its offers follow in a MerchantOffers packet.
let title = plain(
&p.title,
&self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default(),
);
self.remote_open_merchant(p.container, title);
return;
}
let entity = match menu.as_str() {
"minecraft:crafting" => None,
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => {
let kind = match menu.as_str() {
"minecraft:blast_furnace" => FurnaceKind::BlastFurnace,
"minecraft:smoker" => FurnaceKind::Smoker,
_ => FurnaceKind::Furnace,
};
Some(BlockEntity::Furnace(Furnace {
kind,
items: [None, None, None],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: std::collections::HashMap::new(),
}))
}
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
// and droppers).
"minecraft:hopper" => Some(BlockEntity::Chest(vec![None; 5])),
// BrewingStandMenu: its times come in ContainerSetData.
"minecraft:brewing_stand" => Some(BlockEntity::Brewing(Box::new(
horizon_client::brewing::BrewingStand::new(),
))),
"minecraft:generic_3x3" => Some(BlockEntity::Chest(vec![None; 9])),
// CrafterMenu: the disabled slots and triggered come in ContainerSetData.
"minecraft:crafter_3x3" => Some(BlockEntity::Crafter(Default::default())),
m if m.starts_with("minecraft:generic_9x") || m == "minecraft:shulker_box" => {
let rows = m
.strip_prefix("minecraft:generic_9x")
.and_then(|r| r.parse::<usize>().ok())
.unwrap_or(3);
Some(BlockEntity::Chest(vec![None; rows * 9]))
}
_ => {
// Unsupported menu: tell the server we closed it.
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
self.chat_line(
horizon_client::text::Text::literal(format!(
"(Horizon cannot show {menu} screens yet)"
)),
horizon_client::chat::ChatSource::Client,
);
return;
}
};
let title = menu_title(&p.title);
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, menu.clone()));
r.menu_title = Some(title);
}
self.craft_size = if menu == "minecraft:crafting" { 3 } else { 2 };
self.container = None;
if let Some(entity) = entity {
// The clicked block shows the menu when it holds that container;
// an ender chest's or a double chest's slots are no one block's,
// so they go under a key no block has.
let holds = |k: &(i32, i32, i32)| {
let name = &self.res.facts().get(self.world.block(k.0, k.1, k.2)).name;
BlockEntity::for_block(name).is_some_and(|e| {
e.id() == entity.id() && e.slots().len() == entity.slots().len()
})
};
let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0));
self.world.block_entities.insert(key, entity);
self.container = Some(key);
}
self.screen = Screen::Inventory;
self.capture(false);
}
/// `ClientPacketListener.handleMountScreenOpen`: a horse's or llama's
/// inventory; for another entity the window is closed again.
fn remote_open_mount(&mut self, p: play::MountScreenOpen) {
let columns = usize::try_from(p.columns).unwrap_or(0).min(5);
if !self.open_remote_mount_menu(p.entity as u32, columns) {
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
return;
}
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, format!("{MOUNT_MENU}{columns}")));
r.menu_title = None;
}
}
/// The type of the menu a server opened (`MenuType`'s id).
pub(super) fn remote_menu_type(&self) -> Option<&str> {
Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str())
}
/// The title the server gave the menu it opened.
pub(super) fn remote_menu_title(&self) -> Option<horizon_client::text::Text> {
self.remote.as_ref()?.menu_title.clone()
}
/// A language change reaches the server-sent components too: their titles
/// are resolved when the packet arrives, with the session's language.
pub(super) fn refresh_remote_language(&mut self) {
if let Some(lang) = horizon_client::lang::current()
&& let Some(r) = self.remote.as_mut()
{
r.lang = Arc::new(lang.map().clone());
}
}
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
pub(super) fn remote_chest_menu(&self) -> Option<(usize, horizon_client::text::Text)> {
let r = self.remote.as_ref()?;
let rows = r
.menu
.as_ref()?
.1
.strip_prefix("minecraft:generic_9x")?
.parse()
.ok()?;
Some((
rows,
r.menu_title
.clone()
.unwrap_or_else(horizon_client::text::Text::empty),
))
}
/// ServerboundPlayerCommandPacket STOP_SLEEPING.
pub(super) fn remote_stop_sleeping(&mut self) {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 0,
data: 0,
}));
}
pub(super) fn remote_respawn(&mut self) {
self.send(S::ClientCommand(play::ClientCommand { action: 0 }));
}
/// Connecting / loading screens; true when it covered the frame.
pub(super) fn remote_overlay(&self, batch: &mut HudBatch) -> bool {
let Some(r) = &self.remote else { return false };
if self.player.is_some() {
return false;
}
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
let shown = horizon_client::net::shown_address(&r.session.address);
for (i, line) in [r.status.as_str(), shown.as_str()].iter().enumerate() {
let tw = HudBatch::text_width(atlas, line);
batch.text(
atlas,
line,
(w - tw) / 2.0,
h / 2.0 - 12.0 + i as f32 * 12.0,
if i == 0 {
u32::MAX
} else {
rgba(160, 160, 160, 255)
},
true,
);
}
let _ = r.joined;
true
}
/// The server's death message; chat uses the shared ChatComponent.
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
let Some(r) = &self.remote else { return };
let atlas = &self.gui.atlas;
let w = batch.width;
if self.screen == Screen::Dead
&& let Some(message) = &r.death_message
{
// DeathScreen draws the styled cause component.
let tw = atlas.font.width_text(message);
batch.text_styled(
atlas,
message,
((w - tw) / 2.0).floor(),
85.0,
u32::MAX,
true,
);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
let r = self.remote.as_ref()?;
Some(r.glowing.contains(&(id as i32)))
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (from the
/// player list).
pub(super) fn remote_player_names(&self) -> Vec<(u32, String)> {
let Some(r) = self.remote.as_ref() else {
return Vec::new();
};
r.level_players
.iter()
.filter(|(_, uuid)| **uuid != r.profile)
.filter_map(|(id, uuid)| Some((*id as u32, r.players.get(uuid)?.clone())))
.collect()
}
/// `ClientClockManager.tick`: the clock moves on by the game time that
/// passed since its last tick, at its rate. A game time the server sets
/// forward (its sync every second) moves the clock with it.
fn remote_clock_tick(&mut self) {
let Some(r) = self.remote.as_mut() else {
return;
};
let delta = self.world.game_time - r.clock_game_time;
r.clock_game_time = self.world.game_time;
let partial = f64::from(r.clock_partial) + delta as f64 * f64::from(r.clock_rate);
let full = partial.floor();
r.clock_partial = (partial - full) as f32;
self.world.day_time += full as i64;
}
pub(super) fn remote_status(&self) -> Option<serde_json::Value> {
let r = self.remote.as_ref()?;
Some(serde_json::json!({
"address": horizon_client::net::shown_address(&r.session.address),
"name": r.session.name,
"joined": r.joined,
// ServerboundPlayerLoadedPacket sent: the level around the
// player is ready, and the client ticks and sends its moves.
"loaded": r.loaded_sent,
"status": r.status,
"entity_id": r.entity_id,
"dimension": r.dimension.name,
"game_mode": r.game_mode,
"chunks": self.world.chunks.len(),
"entities": r.entities.values().fold(std::collections::BTreeMap::<&str, u32>::new(), |mut m, t| {
*m.entry(t.kind.as_str()).or_default() += 1;
m
}),
"players": r.players.values().collect::<Vec<_>>(),
// Where the other players' entities are (harnesses check movement).
// What the other players hold and wear, as this client draws them.
"player_equipment": self
.mobs
.iter()
.filter(|m| r.level_players.contains_key(&(m.id as i32)))
.map(|m| {
let e = &m.extra;
let armor = [&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref());
serde_json::json!({
"id": m.id,
"swinging": m.ai.swinging,
"using": m.extra.using_item,
"armor": armor,
"main_hand": e.main_hand.as_deref(),
"off_hand": e.off_hand.as_deref(),
})
})
.collect::<Vec<_>>(),
"player_positions": r
.entities
.values()
.filter(|t| t.kind == "minecraft:player")
.map(|t| t.position)
.collect::<Vec<_>>(),
// The other players this client draws dead (lying down, after EntityEvent DEATH).
"dead_players": self
.mobs
.iter()
.filter(|m| r.level_players.contains_key(&(m.id as i32)) && !m.alive())
.map(|m| m.id)
.collect::<Vec<_>>(),
// The other players this client draws lying in a bed (SLEEPING_POS).
"lying_players": self
.mobs
.iter()
.filter(|m| r.level_players.contains_key(&(m.id as i32)) && m.extra.ints[1] != 0)
.map(|m| m.id)
.collect::<Vec<_>>(),
// The player's own model exists (third person and the inventory draw it).
"player_model": self.player_model.is_some(),
"text_display_vertices": self.text_display_vertices,
"text_displays": self.text_displays.len(),
"health": r.health.0,
"food": r.health.1,
"decode_errors": r.decode_errors,
"chat": r.chat.iter().rev().take(10).rev().map(|(t, _)| t.clone()).collect::<Vec<_>>(),
}))
}
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(a: f32) -> f32 {
let a = a % 360.0;
if a >= 180.0 {
a - 360.0
} else if a < -180.0 {
a + 360.0
} else {
a
}
}
/// `LivingEntity.tick`'s body target and `tickHeadTurn` for another player:
/// the body turns toward where it walks (backwards when walking back), or to
/// the look while the arm swings, and stays within 50 degrees of the look.
fn remote_player_head_turn(m: &mut Mob) {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let mut target = m.body_yaw;
if (dx * dx + dz * dz) as f32 > 0.002_500_000_2 {
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing = (wrap_degrees(m.yaw) - walk).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
if m.anim.attack_anim > 0.0 {
target = m.yaw;
}
m.body_yaw += wrap_degrees(target - m.body_yaw) * 0.3;
let head = wrap_degrees(m.yaw - m.body_yaw);
if head.abs() > 50.0 {
m.body_yaw += head - head.signum() * 50.0;
}
}
/// A click sent to a server: its slot, button and input, the slots it
/// changed and the carried stack after it.
type Predicted = (
i16,
i32,
Input,
Vec<(i16, Option<ItemStack>)>,
Option<ItemStack>,
);
fn menu_items(m: &horizon_client::menu::Menu) -> Vec<Option<ItemStack>> {
(0..m.slot_count()).map(|i| m.slot(i).cloned()).collect()
}
/// The slots whose stack is no longer `before`'s (`ItemStack.matches`).
fn changed_slots(
before: &[Option<ItemStack>],
m: &horizon_client::menu::Menu,
) -> Vec<(i16, Option<ItemStack>)> {
(0..m.slot_count())
.filter(|&i| m.slot(i) != before.get(i).and_then(Option::as_ref))
.map(|i| (i as i16, m.slot(i).cloned()))
.collect()
}
/// ServerboundContainerClickPacket's `ContainerInput` ordinal.
pub(super) fn input_ordinal(input: Input) -> i32 {
match input {
Input::Pickup => 0,
Input::QuickMove => 1,
Input::Swap => 2,
Input::Clone => 3,
Input::Throw => 4,
Input::QuickCraft => 5,
Input::PickupAll => 6,
}
}
/// The client's world for a guess at what a click does: reads go to the
/// world, writes stay here and are dropped.
struct DryLevel<'w> {
base: &'w dyn horizon_world::level::LevelAccess,
changed: HashMap<horizon_core::position::BlockPos, BlockStateId>,
random: horizon_core::random::LegacyRandom,
/// The sounds the use plays (a door's creak).
sounds: Vec<DrySound>,
}
type DrySound = (
horizon_core::position::BlockPos,
std::sync::Arc<str>,
f32,
f32,
);
/// The blocks a use on the dry level set.
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
/// What a use on the dry level changed, and the sounds it played.
type DryUse = (DryChanges, Vec<DrySound>);
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
/// no hand use.
pub(super) fn hand_operated(name: &str) -> bool {
let name = name.trim_start_matches("minecraft:");
((name.ends_with("_door") || name.ends_with("_trapdoor")) && !name.starts_with("iron_"))
|| name.ends_with("_fence_gate")
|| name.ends_with("_button")
|| name == "lever"
}
impl<'w> DryLevel<'w> {
fn new(base: &'w dyn horizon_world::level::LevelAccess) -> Self {
Self {
base,
changed: HashMap::new(),
random: horizon_core::random::LegacyRandom::new(0),
sounds: Vec::new(),
}
}
}
impl horizon_world::level::LevelAccess for DryLevel<'_> {
fn emit(&mut self, event: horizon_world::level::LevelEvent) {
if let horizon_world::level::LevelEvent::Sound {
pos,
sound,
volume,
pitch,
} = event
{
self.sounds.push((pos, sound, volume, pitch));
}
}
fn block_state(&self, pos: horizon_core::position::BlockPos) -> BlockStateId {
self.changed
.get(&pos)
.copied()
.unwrap_or_else(|| self.base.block_state(pos))
}
fn set_raw(
&mut self,
pos: horizon_core::position::BlockPos,
state: BlockStateId,
) -> Option<BlockStateId> {
let previous = self.block_state(pos);
self.changed.insert(pos, state);
Some(previous)
}
fn is_loaded(&self, pos: horizon_core::position::BlockPos) -> bool {
self.base.is_loaded(pos)
}
fn min_y(&self) -> i32 {
self.base.min_y()
}
fn max_y(&self) -> i32 {
self.base.max_y()
}
fn block_light(&self, pos: horizon_core::position::BlockPos) -> i32 {
self.base.block_light(pos)
}
fn sky_light(&self, pos: horizon_core::position::BlockPos) -> i32 {
self.base.sky_light(pos)
}
fn sky_darken(&self) -> i32 {
self.base.sky_darken()
}
fn is_unobstructed(&self, pos: horizon_core::position::BlockPos, state: BlockStateId) -> bool {
self.base.is_unobstructed(pos, state)
}
fn random(&mut self) -> &mut horizon_core::random::LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
self.base.game_time()
}
}
/// `InteractionHand` on the wire.
fn hand_id(hand: Hand) -> i32 {
match hand {
Hand::Main => 0,
Hand::Off => 1,
}
}
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
/// its hand. Tools and materials pass, and the off hand gets its turn. The
/// server decides; this is the client's guess of whether to go on.
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
id.ends_with("_spear")
|| info.consumable.is_some()
|| info.equip_slot.is_some()
|| info.blocks_attacks.is_some()
|| horizon_client::items::bucket_contents(&info.id).is_some()
|| id.ends_with("_boat")
|| id.ends_with("_raft")
|| id.ends_with("_bundle")
|| matches!(
id,
"bow"
| "crossbow"
| "trident"
| "spyglass"
| "fishing_rod"
| "snowball"
| "egg"
| "blue_egg"
| "brown_egg"
| "ender_pearl"
| "ender_eye"
| "wind_charge"
| "splash_potion"
| "lingering_potion"
| "experience_bottle"
| "bucket"
| "glass_bottle"
| "map"
| "writable_book"
| "written_book"
| "goat_horn"
| "bundle"
| "brush"
| "carrot_on_a_stick"
| "warped_fungus_on_a_stick"
)
}
/// The view distance a client asks for: its render distance, which Horizon
/// lets go past vanilla's 32 (a vanilla server still caps it at its own).
fn client_view_distance(render_distance: i32) -> i8 {
render_distance.clamp(2, i32::from(i8::MAX)) as i8
}
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>);
struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>);
impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_> {
fn use_entity(&mut self) -> bool {
self.0.remote_use_entity(Hand::Main)
}
fn use_block(&mut self) -> bool {
self.0.remote_use_block(Hand::Main)
}
fn use_item(&mut self) {
self.0.remote_use_hands();
}
}
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
fn using_item(&self) -> bool {
self.0.using.is_some()
}
fn release_using(&mut self) {
self.0.send(S::PlayerAction(play::PlayerAction {
action: 6,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
// MultiPlayerGameMode.releaseUsingItem also runs `Item.releaseUsing` here: the
// player moves by itself, so a Riptide launch happens on the client.
let hand = self.0.used_hand();
if let Some((slot, ticks)) = self.0.using.take()
&& let Some(hand) = hand
&& self
.0
.stack_in(slot)
.is_some_and(|s| &*s.item == "minecraft:trident")
{
self.0.with_hand(hand, |g| g.release_trident(ticks));
}
}
fn start_attack(&mut self) -> bool {
self.0.remote_start_attack()
}
fn start_use(&mut self) {
self.0.remote_use();
}
fn pick(&mut self) {
// Minecraft.pickBlockOrEntity: an entity before the block is asked
// for (`handlePickItemFromEntity`), else the block.
if let Some(entity) = self.0.pick_entity_hit() {
if let Some(id) = entity {
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
id,
include_data: self.0.has_control_down(),
}));
}
} else if let Some(hit) = self.0.pick() {
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
pos: hit.pos,
include_data: self.0.has_control_down(),
}));
}
}
fn use_delay(&self) -> u32 {
self.0.use_delay
}
fn continue_attack(&mut self, down: bool) {
self.0.remote_continue_attack(down);
}
}
impl Remote {
/// Players in the server's player list, this one included.
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
/// The local player's profile UUID.
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
.iter()
.find(|(_, u)| **u == uuid)
.map(|(id, _)| *id as u32)
}
}
#[cfg(test)]
mod tests {
use super::Game;
#[test]
fn placement_preview_checks_obstruction_without_changing_the_level() {
use super::DryLevel;
use horizon_core::{position::BlockPos, random::LegacyRandom};
use horizon_world::{
level::{
Behaviours, LevelAccess, PlaceContext, PlaceOutcome, Ticks, data::load_registry,
},
registry::BlockStateId,
};
struct Level {
air: BlockStateId,
stone: BlockStateId,
obstructed: bool,
loaded: bool,
ceiling: i32,
}
impl LevelAccess for Level {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
if pos == BlockPos::new(0, 0, 0) {
self.stone
} else {
self.air
}
}
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
panic!("preview must not write to the live level");
}
fn is_loaded(&self, _: BlockPos) -> bool {
self.loaded
}
fn max_y(&self) -> i32 {
self.ceiling
}
fn is_unobstructed(&self, pos: BlockPos, _: BlockStateId) -> bool {
assert_eq!(pos, BlockPos::new(0, 1, 0));
!self.obstructed
}
fn random(&mut self) -> &mut LegacyRandom {
panic!("preview must not advance the live level's random source");
}
fn game_time(&self) -> i64 {
0
}
}
// As with the LAN tests, prepared game resources are optional locally.
let resources = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !resources.join("block-behaviours.json").exists() {
return;
}
let registry = load_registry(&resources).unwrap();
let behaviours = Behaviours::load(&registry, &resources).unwrap();
let mut base = Level {
air: registry.default_state("minecraft:air").unwrap(),
stone: registry.default_state("minecraft:stone").unwrap(),
obstructed: false,
loaded: true,
ceiling: 320,
};
for item in [
"minecraft:stone",
"minecraft:oak_slab",
"minecraft:oak_door",
// GrowingPlantHeadBlock.getStateForPlacement draws a random age.
"minecraft:twisting_vines",
] {
let ctx = PlaceContext {
clicked_pos: BlockPos::new(0, 0, 0),
face: horizon_core::geometry::Direction::Up,
hit: horizon_core::geometry::Vec3::new(0.5, 1.0, 0.5),
inside: false,
yaw: 0.0,
pitch: 45.0,
sneaking: false,
item: item.into(),
can_use_game_master_blocks: false,
};
for (obstructed, loaded, ceiling) in [
(false, true, 320),
(true, true, 320),
(false, false, 320),
(false, true, 1),
] {
base.obstructed = obstructed;
base.loaded = loaded;
base.ceiling = ceiling;
let valid = !obstructed && loaded && ceiling > 1;
let mut preview = DryLevel::new(&base);
let outcome = behaviours.place_block(&mut preview, &mut Ticks::default(), &ctx);
assert_eq!(
matches!(outcome, PlaceOutcome::Placed { .. }),
valid,
"{item}, obstructed={obstructed}, loaded={loaded}, ceiling={ceiling}"
);
assert_eq!(preview.changed.is_empty(), !valid);
assert_eq!(base.block_state(BlockPos::new(0, 1, 0)), base.air);
}
}
}
#[test]
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
assert_eq!(Game::menu_slot("inventory", 45), Some(504));
let mut seen = std::collections::HashSet::new();
for slot in 0..46 {
let internal = Game::menu_slot("inventory", slot);
assert!(internal.is_some_and(|i| seen.insert(i)), "slot {slot}");
}
assert_eq!(Game::menu_slot("inventory", 46), None);
}
#[test]
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
for (menu, internal, index) in [
("minecraft:generic_9x6", 18, 63),
("minecraft:generic_9x6", 19, 64),
("minecraft:generic_9x6", 0, 81),
("minecraft:generic_9x6", 8, 89),
("minecraft:generic_9x6", 353, 53),
("minecraft:generic_9x5", 8, 80),
("minecraft:generic_9x4", 8, 71),
("minecraft:generic_9x3", 8, 62),
("inventory", 504, 45),
] {
assert_eq!(
Game::menu_slot_index(menu, internal),
Some(index),
"{menu}, game slot {internal}"
);
}
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
assert_eq!(Game::menu_slot_index("unknown", 0), None);
}
}