mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
3091 lines
116 KiB
Rust
3091 lines
116 KiB
Rust
#![deny(unsafe_code)]
|
|
#![deny(clippy::clone_on_ref_ptr)]
|
|
#![feature(option_zip)]
|
|
|
|
pub mod addr;
|
|
pub mod error;
|
|
|
|
// Reexports
|
|
pub use crate::error::Error;
|
|
pub use authc::AuthClientError;
|
|
pub use common_net::msg::ServerInfo;
|
|
pub use specs::{
|
|
Builder, DispatcherBuilder, Entity as EcsEntity, Join, LendJoin, ReadStorage, World, WorldExt,
|
|
};
|
|
|
|
use crate::addr::ConnectionArgs;
|
|
use byteorder::{ByteOrder, LittleEndian};
|
|
use common::{
|
|
character::{CharacterId, CharacterItem},
|
|
comp::{
|
|
self,
|
|
chat::KillSource,
|
|
controller::{BlockInteraction, CraftEvent},
|
|
dialogue::Subject,
|
|
group,
|
|
inventory::item::{modular, tool, ItemKind},
|
|
invite::{InviteKind, InviteResponse},
|
|
skills::Skill,
|
|
slot::{EquipSlot, InvSlotId, Slot},
|
|
CharacterState, ChatMode, ControlAction, ControlEvent, Controller, ControllerInputs,
|
|
GroupManip, InputKind, InventoryAction, InventoryEvent, InventoryUpdateEvent,
|
|
MapMarkerChange, PresenceKind, UtteranceKind,
|
|
},
|
|
event::{EventBus, LocalEvent, UpdateCharacterMetadata},
|
|
grid::Grid,
|
|
link::Is,
|
|
lod,
|
|
mounting::{Rider, VolumePos, VolumeRider},
|
|
outcome::Outcome,
|
|
recipe::{ComponentRecipeBook, RecipeBook, RepairRecipeBook},
|
|
resources::{GameMode, PlayerEntity, Time, TimeOfDay},
|
|
shared_server_config::ServerConstants,
|
|
spiral::Spiral2d,
|
|
terrain::{
|
|
block::Block, map::MapConfig, neighbors, site::DungeonKindMeta, BiomeKind,
|
|
CoordinateConversions, SiteKindMeta, SpriteKind, TerrainChunk, TerrainChunkSize,
|
|
TerrainGrid,
|
|
},
|
|
trade::{PendingTrade, SitePrices, TradeAction, TradeId, TradeResult},
|
|
uid::{IdMaps, Uid},
|
|
vol::RectVolSize,
|
|
weather::{Weather, WeatherGrid},
|
|
};
|
|
#[cfg(feature = "tracy")] use common_base::plot;
|
|
use common_base::{prof_span, span};
|
|
use common_net::{
|
|
msg::{
|
|
self,
|
|
server::ServerDescription,
|
|
world_msg::{EconomyInfo, PoiInfo, SiteId, SiteInfo},
|
|
ChatTypeContext, ClientGeneral, ClientMsg, ClientRegister, ClientType, DisconnectReason,
|
|
InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate, RegisterError,
|
|
ServerGeneral, ServerInit, ServerRegisterAnswer,
|
|
},
|
|
sync::WorldSyncExt,
|
|
};
|
|
use common_state::State;
|
|
use common_systems::add_local_systems;
|
|
use comp::BuffKind;
|
|
use hashbrown::{HashMap, HashSet};
|
|
use image::DynamicImage;
|
|
use network::{ConnectAddr, Network, Participant, Pid, Stream};
|
|
use num::traits::FloatConst;
|
|
use rayon::prelude::*;
|
|
use specs::Component;
|
|
use std::{
|
|
collections::{BTreeMap, VecDeque},
|
|
mem,
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
use tokio::runtime::Runtime;
|
|
use tracing::{debug, error, trace, warn};
|
|
use vek::*;
|
|
|
|
pub const MAX_SELECTABLE_VIEW_DISTANCE: u32 = 65;
|
|
|
|
const PING_ROLLING_AVERAGE_SECS: usize = 10;
|
|
|
|
#[derive(Debug)]
|
|
pub enum Event {
|
|
Chat(comp::ChatMsg),
|
|
GroupInventoryUpdate(comp::Item, String, Uid),
|
|
InviteComplete {
|
|
target: Uid,
|
|
answer: InviteAnswer,
|
|
kind: InviteKind,
|
|
},
|
|
TradeComplete {
|
|
result: TradeResult,
|
|
trade: PendingTrade,
|
|
},
|
|
Disconnect,
|
|
DisconnectionNotification(u64),
|
|
InventoryUpdated(Vec<InventoryUpdateEvent>),
|
|
Kicked(String),
|
|
Notification(Notification),
|
|
SetViewDistance(u32),
|
|
Outcome(Outcome),
|
|
CharacterCreated(CharacterId),
|
|
CharacterEdited(CharacterId),
|
|
CharacterJoined(UpdateCharacterMetadata),
|
|
CharacterError(String),
|
|
MapMarker(comp::MapMarkerUpdate),
|
|
StartSpectate(Vec3<f32>),
|
|
SpectatePosition(Vec3<f32>),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ClientInitStage {
|
|
/// A connection to the server is being created
|
|
ConnectionEstablish,
|
|
/// Waiting for server version
|
|
WatingForServerVersion,
|
|
/// We're currently authenticating with the server
|
|
Authentication,
|
|
/// Loading map data, site information, recipe information and other
|
|
/// initialization data
|
|
LoadingInitData,
|
|
/// Prepare data received by the server to be used by the client (insert
|
|
/// data into the ECS, render map)
|
|
StartingClient,
|
|
}
|
|
|
|
pub struct WorldData {
|
|
/// Just the "base" layer for LOD; currently includes colors and nothing
|
|
/// else. In the future we'll add more layers, like shadows, rivers, and
|
|
/// probably foliage, cities, roads, and other structures.
|
|
pub lod_base: Grid<u32>,
|
|
/// The "height" layer for LOD; currently includes only land altitudes, but
|
|
/// in the future should also water depth, and probably other
|
|
/// information as well.
|
|
pub lod_alt: Grid<u32>,
|
|
/// The "shadow" layer for LOD. Includes east and west horizon angles and
|
|
/// an approximate max occluder height, which we use to try to
|
|
/// approximate soft and volumetric shadows.
|
|
pub lod_horizon: Grid<u32>,
|
|
/// A fully rendered map image for use with the map and minimap; note that
|
|
/// this can be constructed dynamically by combining the layers of world
|
|
/// map data (e.g. with shadow map data or river data), but at present
|
|
/// we opt not to do this.
|
|
///
|
|
/// The first two elements of the tuple are the regular and topographic maps
|
|
/// respectively. The third element of the tuple is the world size (as a 2D
|
|
/// grid, in chunks), and the fourth element holds the minimum height for
|
|
/// any land chunk (i.e. the sea level) in its x coordinate, and the maximum
|
|
/// land height above this height (i.e. the max height) in its y coordinate.
|
|
map: (Vec<Arc<DynamicImage>>, Vec2<u16>, Vec2<f32>),
|
|
}
|
|
|
|
impl WorldData {
|
|
pub fn chunk_size(&self) -> Vec2<u16> { self.map.1 }
|
|
|
|
pub fn map_layers(&self) -> &Vec<Arc<DynamicImage>> { &self.map.0 }
|
|
|
|
pub fn map_image(&self) -> &Arc<DynamicImage> { &self.map.0[0] }
|
|
|
|
pub fn topo_map_image(&self) -> &Arc<DynamicImage> { &self.map.0[1] }
|
|
|
|
pub fn min_chunk_alt(&self) -> f32 { self.map.2.x }
|
|
|
|
pub fn max_chunk_alt(&self) -> f32 { self.map.2.y }
|
|
}
|
|
|
|
pub struct SiteInfoRich {
|
|
pub site: SiteInfo,
|
|
pub economy: Option<EconomyInfo>,
|
|
}
|
|
|
|
struct WeatherLerp {
|
|
old: (WeatherGrid, Instant),
|
|
new: (WeatherGrid, Instant),
|
|
}
|
|
|
|
impl WeatherLerp {
|
|
fn weather_update(&mut self, weather: WeatherGrid) {
|
|
self.old = mem::replace(&mut self.new, (weather, Instant::now()));
|
|
}
|
|
|
|
// TODO: Make improvements to this interpolation, it's main issue is assuming
|
|
// that updates come at regular intervals.
|
|
fn update(&mut self, to_update: &mut WeatherGrid) {
|
|
prof_span!("WeatherLerp::update");
|
|
let old = &self.old.0;
|
|
let new = &self.new.0;
|
|
if new.size() == Vec2::zero() {
|
|
return;
|
|
}
|
|
if to_update.size() != new.size() {
|
|
*to_update = new.clone();
|
|
}
|
|
if old.size() == new.size() {
|
|
// Assumes updates are regular
|
|
let t = (self.new.1.elapsed().as_secs_f32()
|
|
/ self.new.1.duration_since(self.old.1).as_secs_f32())
|
|
.clamp(0.0, 1.0);
|
|
|
|
to_update
|
|
.iter_mut()
|
|
.zip(old.iter().zip(new.iter()))
|
|
.for_each(|((_, current), ((_, old), (_, new)))| {
|
|
*current = Weather::lerp_unclamped(old, new, t);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for WeatherLerp {
|
|
fn default() -> Self {
|
|
Self {
|
|
old: (WeatherGrid::new(Vec2::zero()), Instant::now()),
|
|
new: (WeatherGrid::new(Vec2::zero()), Instant::now()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct Client {
|
|
registered: bool,
|
|
presence: Option<PresenceKind>,
|
|
runtime: Arc<Runtime>,
|
|
server_info: ServerInfo,
|
|
/// Localized server motd and rules
|
|
server_description: ServerDescription,
|
|
world_data: WorldData,
|
|
weather: WeatherLerp,
|
|
player_list: HashMap<Uid, PlayerInfo>,
|
|
character_list: CharacterList,
|
|
sites: HashMap<SiteId, SiteInfoRich>,
|
|
possible_starting_sites: Vec<SiteId>,
|
|
pois: Vec<PoiInfo>,
|
|
pub chat_mode: ChatMode,
|
|
recipe_book: RecipeBook,
|
|
component_recipe_book: ComponentRecipeBook,
|
|
repair_recipe_book: RepairRecipeBook,
|
|
available_recipes: HashMap<String, Option<SpriteKind>>,
|
|
lod_zones: HashMap<Vec2<i32>, lod::Zone>,
|
|
lod_last_requested: Option<Instant>,
|
|
lod_pos_fallback: Option<Vec2<f32>>,
|
|
force_update_counter: u64,
|
|
|
|
max_group_size: u32,
|
|
// Client has received an invite (inviter uid, time out instant)
|
|
invite: Option<(Uid, Instant, Duration, InviteKind)>,
|
|
group_leader: Option<Uid>,
|
|
// Note: potentially representable as a client only component
|
|
group_members: HashMap<Uid, group::Role>,
|
|
// Pending invites that this client has sent out
|
|
pending_invites: HashSet<Uid>,
|
|
// The pending trade the client is involved in, and it's id
|
|
pending_trade: Option<(TradeId, PendingTrade, Option<SitePrices>)>,
|
|
|
|
network: Option<Network>,
|
|
participant: Option<Participant>,
|
|
general_stream: Stream,
|
|
ping_stream: Stream,
|
|
register_stream: Stream,
|
|
character_screen_stream: Stream,
|
|
in_game_stream: Stream,
|
|
terrain_stream: Stream,
|
|
|
|
client_timeout: Duration,
|
|
last_server_ping: f64,
|
|
last_server_pong: f64,
|
|
last_ping_delta: f64,
|
|
ping_deltas: VecDeque<f64>,
|
|
|
|
tick: u64,
|
|
state: State,
|
|
|
|
flashing_lights_enabled: bool,
|
|
|
|
/// Terrrain view distance
|
|
server_view_distance_limit: Option<u32>,
|
|
view_distance: Option<u32>,
|
|
lod_distance: f32,
|
|
// TODO: move into voxygen
|
|
loaded_distance: f32,
|
|
|
|
pending_chunks: HashMap<Vec2<i32>, Instant>,
|
|
target_time_of_day: Option<TimeOfDay>,
|
|
dt_adjustment: f64,
|
|
|
|
connected_server_constants: ServerConstants,
|
|
}
|
|
|
|
/// Holds data related to the current players characters, as well as some
|
|
/// additional state to handle UI.
|
|
#[derive(Debug, Default)]
|
|
pub struct CharacterList {
|
|
pub characters: Vec<CharacterItem>,
|
|
pub loading: bool,
|
|
}
|
|
|
|
impl Client {
|
|
pub async fn new(
|
|
addr: ConnectionArgs,
|
|
runtime: Arc<Runtime>,
|
|
// TODO: refactor to avoid needing to use this out parameter
|
|
mismatched_server_info: &mut Option<ServerInfo>,
|
|
username: &str,
|
|
password: &str,
|
|
locale: Option<String>,
|
|
auth_trusted: impl FnMut(&str) -> bool,
|
|
init_stage_update: &(dyn Fn(ClientInitStage) + Send + Sync),
|
|
add_foreign_systems: impl Fn(&mut DispatcherBuilder) + Send + 'static,
|
|
) -> Result<Self, Error> {
|
|
let network = Network::new(Pid::new(), &runtime);
|
|
|
|
init_stage_update(ClientInitStage::ConnectionEstablish);
|
|
let mut participant = match addr {
|
|
ConnectionArgs::Tcp {
|
|
hostname,
|
|
prefer_ipv6,
|
|
} => addr::try_connect(&network, &hostname, prefer_ipv6, ConnectAddr::Tcp).await?,
|
|
ConnectionArgs::Quic {
|
|
hostname,
|
|
prefer_ipv6,
|
|
} => {
|
|
warn!(
|
|
"QUIC is enabled. This is experimental and you won't be able to connect to \
|
|
TCP servers unless deactivated"
|
|
);
|
|
let config = quinn::ClientConfig::with_native_roots();
|
|
addr::try_connect(&network, &hostname, prefer_ipv6, |a| {
|
|
ConnectAddr::Quic(a, config.clone(), hostname.clone())
|
|
})
|
|
.await?
|
|
},
|
|
ConnectionArgs::Mpsc(id) => network.connect(ConnectAddr::Mpsc(id)).await?,
|
|
};
|
|
|
|
let stream = participant.opened().await?;
|
|
let ping_stream = participant.opened().await?;
|
|
let mut register_stream = participant.opened().await?;
|
|
let character_screen_stream = participant.opened().await?;
|
|
let in_game_stream = participant.opened().await?;
|
|
let terrain_stream = participant.opened().await?;
|
|
|
|
init_stage_update(ClientInitStage::WatingForServerVersion);
|
|
register_stream.send(ClientType::Game)?;
|
|
let server_info: ServerInfo = register_stream.recv().await?;
|
|
if server_info.git_hash != *common::util::GIT_HASH {
|
|
warn!(
|
|
"Server is running {}[{}], you are running {}[{}], versions might be incompatible!",
|
|
server_info.git_hash,
|
|
server_info.git_date,
|
|
common::util::GIT_HASH.to_string(),
|
|
*common::util::GIT_DATE,
|
|
);
|
|
}
|
|
// Pass the server info back to the caller to ensure they can access it even
|
|
// if this function errors.
|
|
mem::swap(mismatched_server_info, &mut Some(server_info.clone()));
|
|
debug!("Auth Server: {:?}", server_info.auth_provider);
|
|
|
|
ping_stream.send(PingMsg::Ping)?;
|
|
|
|
init_stage_update(ClientInitStage::Authentication);
|
|
// Register client
|
|
Self::register(
|
|
username,
|
|
password,
|
|
locale,
|
|
auth_trusted,
|
|
&server_info,
|
|
&mut register_stream,
|
|
)
|
|
.await?;
|
|
|
|
init_stage_update(ClientInitStage::LoadingInitData);
|
|
// Wait for initial sync
|
|
let mut ping_interval = tokio::time::interval(Duration::from_secs(1));
|
|
let ServerInit::GameSync {
|
|
entity_package,
|
|
time_of_day,
|
|
max_group_size,
|
|
client_timeout,
|
|
world_map,
|
|
recipe_book,
|
|
component_recipe_book,
|
|
material_stats,
|
|
ability_map,
|
|
server_constants,
|
|
repair_recipe_book,
|
|
description,
|
|
} = loop {
|
|
tokio::select! {
|
|
// Spawn in a blocking thread (leaving the network thread free). This is mostly
|
|
// useful for bots.
|
|
res = register_stream.recv() => break res?,
|
|
_ = ping_interval.tick() => ping_stream.send(PingMsg::Ping)?,
|
|
}
|
|
};
|
|
|
|
init_stage_update(ClientInitStage::StartingClient);
|
|
// Spawn in a blocking thread (leaving the network thread free). This is mostly
|
|
// useful for bots.
|
|
let mut task = tokio::task::spawn_blocking(move || {
|
|
let map_size_lg =
|
|
common::terrain::MapSizeLg::new(world_map.dimensions_lg).map_err(|_| {
|
|
Error::Other(format!(
|
|
"Server sent bad world map dimensions: {:?}",
|
|
world_map.dimensions_lg,
|
|
))
|
|
})?;
|
|
let sea_level = world_map.default_chunk.get_min_z() as f32;
|
|
|
|
// Initialize `State`
|
|
let pools = State::pools(GameMode::Client);
|
|
let mut state = State::client(
|
|
pools,
|
|
map_size_lg,
|
|
world_map.default_chunk,
|
|
// TODO: Add frontend systems
|
|
|dispatch_builder| {
|
|
add_local_systems(dispatch_builder);
|
|
add_foreign_systems(dispatch_builder);
|
|
},
|
|
);
|
|
// Client-only components
|
|
state.ecs_mut().register::<comp::Last<CharacterState>>();
|
|
let entity = state.ecs_mut().apply_entity_package(entity_package);
|
|
*state.ecs_mut().write_resource() = time_of_day;
|
|
*state.ecs_mut().write_resource() = PlayerEntity(Some(entity));
|
|
state.ecs_mut().insert(material_stats);
|
|
state.ecs_mut().insert(ability_map);
|
|
|
|
let map_size = map_size_lg.chunks();
|
|
let max_height = world_map.max_height;
|
|
let rgba = world_map.rgba;
|
|
let alt = world_map.alt;
|
|
if rgba.size() != map_size.map(|e| e as i32) {
|
|
return Err(Error::Other("Server sent a bad world map image".into()));
|
|
}
|
|
if alt.size() != map_size.map(|e| e as i32) {
|
|
return Err(Error::Other("Server sent a bad altitude map.".into()));
|
|
}
|
|
let [west, east] = world_map.horizons;
|
|
let scale_angle = |a: u8| (a as f32 / 255.0 * <f32 as FloatConst>::FRAC_PI_2()).tan();
|
|
let scale_height = |h: u8| h as f32 / 255.0 * max_height;
|
|
let scale_height_big = |h: u32| (h >> 3) as f32 / 8191.0 * max_height;
|
|
|
|
debug!("Preparing image...");
|
|
let unzip_horizons = |(angles, heights): &(Vec<_>, Vec<_>)| {
|
|
(
|
|
angles.iter().copied().map(scale_angle).collect::<Vec<_>>(),
|
|
heights
|
|
.iter()
|
|
.copied()
|
|
.map(scale_height)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
};
|
|
let horizons = [unzip_horizons(&west), unzip_horizons(&east)];
|
|
|
|
// Redraw map (with shadows this time).
|
|
let mut world_map_rgba = vec![0u32; rgba.size().product() as usize];
|
|
let mut world_map_topo = vec![0u32; rgba.size().product() as usize];
|
|
let mut map_config = common::terrain::map::MapConfig::orthographic(
|
|
map_size_lg,
|
|
core::ops::RangeInclusive::new(0.0, max_height),
|
|
);
|
|
map_config.horizons = Some(&horizons);
|
|
let rescale_height = |h: f32| h / max_height;
|
|
let bounds_check = |pos: Vec2<i32>| {
|
|
pos.reduce_partial_min() >= 0
|
|
&& pos.x < map_size.x as i32
|
|
&& pos.y < map_size.y as i32
|
|
};
|
|
fn sample_pos(
|
|
map_config: &MapConfig,
|
|
pos: Vec2<i32>,
|
|
alt: &Grid<u32>,
|
|
rgba: &Grid<u32>,
|
|
map_size: &Vec2<u16>,
|
|
map_size_lg: &common::terrain::MapSizeLg,
|
|
max_height: f32,
|
|
) -> common::terrain::map::MapSample {
|
|
let rescale_height = |h: f32| h / max_height;
|
|
let scale_height_big = |h: u32| (h >> 3) as f32 / 8191.0 * max_height;
|
|
let bounds_check = |pos: Vec2<i32>| {
|
|
pos.reduce_partial_min() >= 0
|
|
&& pos.x < map_size.x as i32
|
|
&& pos.y < map_size.y as i32
|
|
};
|
|
let MapConfig {
|
|
gain,
|
|
is_contours,
|
|
is_height_map,
|
|
is_stylized_topo,
|
|
..
|
|
} = *map_config;
|
|
let mut is_contour_line = false;
|
|
let mut is_border = false;
|
|
let (rgb, alt, downhill_wpos) = if bounds_check(pos) {
|
|
let posi = pos.y as usize * map_size.x as usize + pos.x as usize;
|
|
let [r, g, b, _a] = rgba[pos].to_le_bytes();
|
|
let is_water = r == 0 && b > 102 && g < 77;
|
|
let alti = alt[pos];
|
|
// Compute contours (chunks are assigned in the river code below)
|
|
let altj = rescale_height(scale_height_big(alti));
|
|
let contour_interval = 150.0;
|
|
let chunk_contour = (altj * gain / contour_interval) as u32;
|
|
|
|
// Compute downhill.
|
|
let downhill = {
|
|
let mut best = -1;
|
|
let mut besth = alti;
|
|
for nposi in neighbors(*map_size_lg, posi) {
|
|
let nbh = alt.raw()[nposi];
|
|
let nalt = rescale_height(scale_height_big(nbh));
|
|
let nchunk_contour = (nalt * gain / contour_interval) as u32;
|
|
if !is_contour_line && chunk_contour > nchunk_contour {
|
|
is_contour_line = true;
|
|
}
|
|
let [nr, ng, nb, _na] = rgba.raw()[nposi].to_le_bytes();
|
|
let n_is_water = nr == 0 && nb > 102 && ng < 77;
|
|
|
|
if !is_border && is_water && !n_is_water {
|
|
is_border = true;
|
|
}
|
|
|
|
if nbh < besth {
|
|
besth = nbh;
|
|
best = nposi as isize;
|
|
}
|
|
}
|
|
best
|
|
};
|
|
let downhill_wpos = if downhill < 0 {
|
|
None
|
|
} else {
|
|
Some(
|
|
Vec2::new(
|
|
(downhill as usize % map_size.x as usize) as i32,
|
|
(downhill as usize / map_size.x as usize) as i32,
|
|
) * TerrainChunkSize::RECT_SIZE.map(|e| e as i32),
|
|
)
|
|
};
|
|
(Rgb::new(r, g, b), alti, downhill_wpos)
|
|
} else {
|
|
(Rgb::zero(), 0, None)
|
|
};
|
|
let alt = f64::from(rescale_height(scale_height_big(alt)));
|
|
let wpos = pos * TerrainChunkSize::RECT_SIZE.map(|e| e as i32);
|
|
let downhill_wpos =
|
|
downhill_wpos.unwrap_or(wpos + TerrainChunkSize::RECT_SIZE.map(|e| e as i32));
|
|
let is_path = rgb.r == 0x37 && rgb.g == 0x29 && rgb.b == 0x23;
|
|
let rgb = rgb.map(|e: u8| e as f64 / 255.0);
|
|
let is_water = rgb.r == 0.0 && rgb.b > 0.4 && rgb.g < 0.3;
|
|
|
|
let rgb = if is_height_map {
|
|
if is_path {
|
|
// Path color is Rgb::new(0x37, 0x29, 0x23)
|
|
Rgb::new(0.9, 0.9, 0.63)
|
|
} else if is_water {
|
|
Rgb::new(0.23, 0.47, 0.53)
|
|
} else if is_contours && is_contour_line {
|
|
// Color contour lines
|
|
Rgb::new(0.15, 0.15, 0.15)
|
|
} else {
|
|
// Color hill shading
|
|
let lightness = (alt + 0.2).min(1.0);
|
|
Rgb::new(lightness, 0.9 * lightness, 0.5 * lightness)
|
|
}
|
|
} else if is_stylized_topo {
|
|
if is_path {
|
|
Rgb::new(0.9, 0.9, 0.63)
|
|
} else if is_water {
|
|
if is_border {
|
|
Rgb::new(0.10, 0.34, 0.50)
|
|
} else {
|
|
Rgb::new(0.23, 0.47, 0.63)
|
|
}
|
|
} else if is_contour_line {
|
|
Rgb::new(0.25, 0.25, 0.25)
|
|
} else {
|
|
// Stylized colors
|
|
Rgb::new(
|
|
(rgb.r + 0.25).min(1.0),
|
|
(rgb.g + 0.23).min(1.0),
|
|
(rgb.b + 0.10).min(1.0),
|
|
)
|
|
}
|
|
} else {
|
|
Rgb::new(rgb.r, rgb.g, rgb.b)
|
|
}
|
|
.map(|e| (e * 255.0) as u8);
|
|
common::terrain::map::MapSample {
|
|
rgb,
|
|
alt,
|
|
downhill_wpos,
|
|
connections: None,
|
|
}
|
|
}
|
|
// Generate standard shaded map
|
|
map_config.is_shaded = true;
|
|
map_config.generate(
|
|
|pos| {
|
|
sample_pos(
|
|
&map_config,
|
|
pos,
|
|
&alt,
|
|
&rgba,
|
|
&map_size,
|
|
&map_size_lg,
|
|
max_height,
|
|
)
|
|
},
|
|
|wpos| {
|
|
let pos = wpos.wpos_to_cpos();
|
|
rescale_height(if bounds_check(pos) {
|
|
scale_height_big(alt[pos])
|
|
} else {
|
|
0.0
|
|
})
|
|
},
|
|
|pos, (r, g, b, a)| {
|
|
world_map_rgba[pos.y * map_size.x as usize + pos.x] =
|
|
u32::from_le_bytes([r, g, b, a]);
|
|
},
|
|
);
|
|
// Generate map with topographical lines and stylized colors
|
|
map_config.is_contours = true;
|
|
map_config.is_stylized_topo = true;
|
|
map_config.generate(
|
|
|pos| {
|
|
sample_pos(
|
|
&map_config,
|
|
pos,
|
|
&alt,
|
|
&rgba,
|
|
&map_size,
|
|
&map_size_lg,
|
|
max_height,
|
|
)
|
|
},
|
|
|wpos| {
|
|
let pos = wpos.wpos_to_cpos();
|
|
rescale_height(if bounds_check(pos) {
|
|
scale_height_big(alt[pos])
|
|
} else {
|
|
0.0
|
|
})
|
|
},
|
|
|pos, (r, g, b, a)| {
|
|
world_map_topo[pos.y * map_size.x as usize + pos.x] =
|
|
u32::from_le_bytes([r, g, b, a]);
|
|
},
|
|
);
|
|
let make_raw = |rgb| -> Result<_, Error> {
|
|
let mut raw = vec![0u8; 4 * world_map_rgba.len()];
|
|
LittleEndian::write_u32_into(rgb, &mut raw);
|
|
Ok(Arc::new(
|
|
DynamicImage::ImageRgba8({
|
|
// Should not fail if the dimensions are correct.
|
|
let map =
|
|
image::ImageBuffer::from_raw(u32::from(map_size.x), u32::from(map_size.y), raw);
|
|
map.ok_or_else(|| Error::Other("Server sent a bad world map image".into()))?
|
|
})
|
|
// Flip the image, since Voxygen uses an orientation where rotation from
|
|
// positive x axis to positive y axis is counterclockwise around the z axis.
|
|
.flipv(),
|
|
))
|
|
};
|
|
let lod_base = rgba;
|
|
let lod_alt = alt;
|
|
let world_map_rgb_img = make_raw(&world_map_rgba)?;
|
|
let world_map_topo_img = make_raw(&world_map_topo)?;
|
|
let world_map_layers = vec![world_map_rgb_img, world_map_topo_img];
|
|
let horizons = (west.0, west.1, east.0, east.1)
|
|
.into_par_iter()
|
|
.map(|(wa, wh, ea, eh)| u32::from_le_bytes([wa, wh, ea, eh]))
|
|
.collect::<Vec<_>>();
|
|
let lod_horizon = horizons;
|
|
let map_bounds = Vec2::new(sea_level, max_height);
|
|
debug!("Done preparing image...");
|
|
|
|
Ok((
|
|
state,
|
|
lod_base,
|
|
lod_alt,
|
|
Grid::from_raw(map_size.map(|e| e as i32), lod_horizon),
|
|
(world_map_layers, map_size, map_bounds),
|
|
world_map.sites,
|
|
world_map.possible_starting_sites,
|
|
world_map.pois,
|
|
recipe_book,
|
|
component_recipe_book,
|
|
repair_recipe_book,
|
|
max_group_size,
|
|
client_timeout,
|
|
))
|
|
});
|
|
|
|
let (
|
|
state,
|
|
lod_base,
|
|
lod_alt,
|
|
lod_horizon,
|
|
world_map,
|
|
sites,
|
|
possible_starting_sites,
|
|
pois,
|
|
recipe_book,
|
|
component_recipe_book,
|
|
repair_recipe_book,
|
|
max_group_size,
|
|
client_timeout,
|
|
) = loop {
|
|
tokio::select! {
|
|
res = &mut task => break res.expect("Client thread should not panic")?,
|
|
_ = ping_interval.tick() => ping_stream.send(PingMsg::Ping)?,
|
|
}
|
|
};
|
|
ping_stream.send(PingMsg::Ping)?;
|
|
|
|
debug!("Initial sync done");
|
|
|
|
Ok(Self {
|
|
registered: true,
|
|
presence: None,
|
|
runtime,
|
|
server_info,
|
|
server_description: description,
|
|
world_data: WorldData {
|
|
lod_base,
|
|
lod_alt,
|
|
lod_horizon,
|
|
map: world_map,
|
|
},
|
|
weather: WeatherLerp::default(),
|
|
player_list: HashMap::new(),
|
|
character_list: CharacterList::default(),
|
|
sites: sites
|
|
.iter()
|
|
.map(|s| {
|
|
(s.id, SiteInfoRich {
|
|
site: s.clone(),
|
|
economy: None,
|
|
})
|
|
})
|
|
.collect(),
|
|
possible_starting_sites,
|
|
pois,
|
|
recipe_book,
|
|
component_recipe_book,
|
|
repair_recipe_book,
|
|
available_recipes: HashMap::default(),
|
|
chat_mode: ChatMode::default(),
|
|
|
|
lod_zones: HashMap::new(),
|
|
lod_last_requested: None,
|
|
lod_pos_fallback: None,
|
|
|
|
force_update_counter: 0,
|
|
|
|
max_group_size,
|
|
invite: None,
|
|
group_leader: None,
|
|
group_members: HashMap::new(),
|
|
pending_invites: HashSet::new(),
|
|
pending_trade: None,
|
|
|
|
network: Some(network),
|
|
participant: Some(participant),
|
|
general_stream: stream,
|
|
ping_stream,
|
|
register_stream,
|
|
character_screen_stream,
|
|
in_game_stream,
|
|
terrain_stream,
|
|
|
|
client_timeout,
|
|
|
|
last_server_ping: 0.0,
|
|
last_server_pong: 0.0,
|
|
last_ping_delta: 0.0,
|
|
ping_deltas: VecDeque::new(),
|
|
|
|
tick: 0,
|
|
state,
|
|
|
|
flashing_lights_enabled: true,
|
|
|
|
server_view_distance_limit: None,
|
|
view_distance: None,
|
|
lod_distance: 4.0,
|
|
loaded_distance: 0.0,
|
|
|
|
pending_chunks: HashMap::new(),
|
|
target_time_of_day: None,
|
|
dt_adjustment: 1.0,
|
|
|
|
connected_server_constants: server_constants,
|
|
})
|
|
}
|
|
|
|
/// Request a state transition to `ClientState::Registered`.
|
|
async fn register(
|
|
username: &str,
|
|
password: &str,
|
|
locale: Option<String>,
|
|
mut auth_trusted: impl FnMut(&str) -> bool,
|
|
server_info: &ServerInfo,
|
|
register_stream: &mut Stream,
|
|
) -> Result<(), Error> {
|
|
// Authentication
|
|
let token_or_username = match &server_info.auth_provider {
|
|
Some(addr) => {
|
|
// Query whether this is a trusted auth server
|
|
if auth_trusted(addr) {
|
|
let (scheme, authority) = match addr.split_once("://") {
|
|
Some((s, a)) => (s, a),
|
|
None => return Err(Error::AuthServerUrlInvalid(addr.to_string())),
|
|
};
|
|
|
|
let scheme = match scheme.parse::<authc::Scheme>() {
|
|
Ok(s) => s,
|
|
Err(_) => return Err(Error::AuthServerUrlInvalid(addr.to_string())),
|
|
};
|
|
|
|
let authority = match authority.parse::<authc::Authority>() {
|
|
Ok(a) => a,
|
|
Err(_) => return Err(Error::AuthServerUrlInvalid(addr.to_string())),
|
|
};
|
|
|
|
Ok(authc::AuthClient::new(scheme, authority)?
|
|
.sign_in(username, password)
|
|
.await?
|
|
.serialize())
|
|
} else {
|
|
Err(Error::AuthServerNotTrusted)
|
|
}
|
|
},
|
|
None => Ok(username.to_owned()),
|
|
}?;
|
|
|
|
debug!("Registering client...");
|
|
|
|
register_stream.send(ClientRegister {
|
|
token_or_username,
|
|
locale,
|
|
})?;
|
|
|
|
match register_stream.recv::<ServerRegisterAnswer>().await? {
|
|
Err(RegisterError::AuthError(err)) => Err(Error::AuthErr(err)),
|
|
Err(RegisterError::InvalidCharacter) => Err(Error::InvalidCharacter),
|
|
Err(RegisterError::NotOnWhitelist) => Err(Error::NotOnWhitelist),
|
|
Err(RegisterError::Kicked(err)) => Err(Error::Kicked(err)),
|
|
Err(RegisterError::Banned(reason)) => Err(Error::Banned(reason)),
|
|
Err(RegisterError::TooManyPlayers) => Err(Error::TooManyPlayers),
|
|
Ok(()) => {
|
|
debug!("Client registered successfully.");
|
|
Ok(())
|
|
},
|
|
}
|
|
}
|
|
|
|
fn send_msg_err<S>(&mut self, msg: S) -> Result<(), network::StreamError>
|
|
where
|
|
S: Into<ClientMsg>,
|
|
{
|
|
prof_span!("send_msg_err");
|
|
let msg: ClientMsg = msg.into();
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
const C_TYPE: ClientType = ClientType::Game;
|
|
let verified = msg.verify(C_TYPE, self.registered, self.presence);
|
|
|
|
// Due to the fact that character loading is performed asynchronously after
|
|
// initial connect it is possible to receive messages after a character load
|
|
// error while in the wrong state.
|
|
if !verified {
|
|
warn!(
|
|
"Received ClientType::Game message when not in game (Registered: {} Presence: \
|
|
{:?}), dropping message: {:?} ",
|
|
self.registered, self.presence, msg
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
match msg {
|
|
ClientMsg::Type(msg) => self.register_stream.send(msg),
|
|
ClientMsg::Register(msg) => self.register_stream.send(msg),
|
|
ClientMsg::General(msg) => {
|
|
#[cfg(feature = "tracy")]
|
|
let (mut ingame, mut terrain) = (0.0, 0.0);
|
|
let stream = match msg {
|
|
ClientGeneral::RequestCharacterList
|
|
| ClientGeneral::CreateCharacter { .. }
|
|
| ClientGeneral::EditCharacter { .. }
|
|
| ClientGeneral::DeleteCharacter(_)
|
|
| ClientGeneral::Character(_, _)
|
|
| ClientGeneral::Spectate(_) => &mut self.character_screen_stream,
|
|
// Only in game
|
|
ClientGeneral::ControllerInputs(_)
|
|
| ClientGeneral::ControlEvent(_)
|
|
| ClientGeneral::ControlAction(_)
|
|
| ClientGeneral::SetViewDistance(_)
|
|
| ClientGeneral::BreakBlock(_)
|
|
| ClientGeneral::PlaceBlock(_, _)
|
|
| ClientGeneral::ExitInGame
|
|
| ClientGeneral::PlayerPhysics { .. }
|
|
| ClientGeneral::UnlockSkill(_)
|
|
| ClientGeneral::RequestSiteInfo(_)
|
|
| ClientGeneral::RequestPlayerPhysics { .. }
|
|
| ClientGeneral::RequestLossyTerrainCompression { .. }
|
|
| ClientGeneral::UpdateMapMarker(_)
|
|
| ClientGeneral::SpectatePosition(_) => {
|
|
#[cfg(feature = "tracy")]
|
|
{
|
|
ingame = 1.0;
|
|
}
|
|
&mut self.in_game_stream
|
|
},
|
|
// Terrain
|
|
ClientGeneral::TerrainChunkRequest { .. }
|
|
| ClientGeneral::LodZoneRequest { .. } => {
|
|
#[cfg(feature = "tracy")]
|
|
{
|
|
terrain = 1.0;
|
|
}
|
|
&mut self.terrain_stream
|
|
},
|
|
// Always possible
|
|
ClientGeneral::ChatMsg(_)
|
|
| ClientGeneral::Command(_, _)
|
|
| ClientGeneral::Terminate => &mut self.general_stream,
|
|
};
|
|
#[cfg(feature = "tracy")]
|
|
{
|
|
plot!("ingame_sends", ingame);
|
|
plot!("terrain_sends", terrain);
|
|
}
|
|
stream.send(msg)
|
|
},
|
|
ClientMsg::Ping(msg) => self.ping_stream.send(msg),
|
|
}
|
|
}
|
|
|
|
pub fn request_player_physics(&mut self, server_authoritative: bool) {
|
|
self.send_msg(ClientGeneral::RequestPlayerPhysics {
|
|
server_authoritative,
|
|
})
|
|
}
|
|
|
|
pub fn request_lossy_terrain_compression(&mut self, lossy_terrain_compression: bool) {
|
|
self.send_msg(ClientGeneral::RequestLossyTerrainCompression {
|
|
lossy_terrain_compression,
|
|
})
|
|
}
|
|
|
|
fn send_msg<S>(&mut self, msg: S)
|
|
where
|
|
S: Into<ClientMsg>,
|
|
{
|
|
let res = self.send_msg_err(msg);
|
|
if let Err(e) = res {
|
|
warn!(
|
|
?e,
|
|
"connection to server no longer possible, couldn't send msg"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Request a state transition to `ClientState::Character`.
|
|
pub fn request_character(
|
|
&mut self,
|
|
character_id: CharacterId,
|
|
view_distances: common::ViewDistances,
|
|
) {
|
|
let view_distances = self.set_view_distances_local(view_distances);
|
|
self.send_msg(ClientGeneral::Character(character_id, view_distances));
|
|
|
|
// Assume we are in_game unless server tells us otherwise
|
|
self.presence = Some(PresenceKind::Character(character_id));
|
|
}
|
|
|
|
/// Request a state transition to `ClientState::Spectate`.
|
|
pub fn request_spectate(&mut self, view_distances: common::ViewDistances) {
|
|
let view_distances = self.set_view_distances_local(view_distances);
|
|
self.send_msg(ClientGeneral::Spectate(view_distances));
|
|
|
|
self.presence = Some(PresenceKind::Spectator);
|
|
}
|
|
|
|
/// Load the current players character list
|
|
pub fn load_character_list(&mut self) {
|
|
self.character_list.loading = true;
|
|
self.send_msg(ClientGeneral::RequestCharacterList);
|
|
}
|
|
|
|
/// New character creation
|
|
pub fn create_character(
|
|
&mut self,
|
|
alias: String,
|
|
mainhand: Option<String>,
|
|
offhand: Option<String>,
|
|
body: comp::Body,
|
|
start_site: Option<SiteId>,
|
|
) {
|
|
self.character_list.loading = true;
|
|
self.send_msg(ClientGeneral::CreateCharacter {
|
|
alias,
|
|
mainhand,
|
|
offhand,
|
|
body,
|
|
start_site,
|
|
});
|
|
}
|
|
|
|
pub fn edit_character(&mut self, alias: String, id: CharacterId, body: comp::Body) {
|
|
self.character_list.loading = true;
|
|
self.send_msg(ClientGeneral::EditCharacter { alias, id, body });
|
|
}
|
|
|
|
/// Character deletion
|
|
pub fn delete_character(&mut self, character_id: CharacterId) {
|
|
// Pre-emptively remove the character to be deleted from the character list as
|
|
// character deletes are processed asynchronously by the server so we can't rely
|
|
// on a timely response to update the character list
|
|
if let Some(pos) = self
|
|
.character_list
|
|
.characters
|
|
.iter()
|
|
.position(|x| x.character.id == Some(character_id))
|
|
{
|
|
self.character_list.characters.remove(pos);
|
|
}
|
|
self.send_msg(ClientGeneral::DeleteCharacter(character_id));
|
|
}
|
|
|
|
/// Send disconnect message to the server
|
|
pub fn logout(&mut self) {
|
|
debug!("Sending logout from server");
|
|
self.send_msg(ClientGeneral::Terminate);
|
|
self.registered = false;
|
|
self.presence = None;
|
|
}
|
|
|
|
/// Request a state transition to `ClientState::Registered` from an ingame
|
|
/// state.
|
|
pub fn request_remove_character(&mut self) {
|
|
self.chat_mode = ChatMode::World;
|
|
self.send_msg(ClientGeneral::ExitInGame);
|
|
}
|
|
|
|
pub fn set_view_distances(&mut self, view_distances: common::ViewDistances) {
|
|
let view_distances = self.set_view_distances_local(view_distances);
|
|
self.send_msg(ClientGeneral::SetViewDistance(view_distances));
|
|
}
|
|
|
|
/// Clamps provided view distances, locally sets the terrain view distance
|
|
/// in the client's properties and returns the clamped values for the
|
|
/// caller to send to the server.
|
|
fn set_view_distances_local(
|
|
&mut self,
|
|
view_distances: common::ViewDistances,
|
|
) -> common::ViewDistances {
|
|
let view_distances = common::ViewDistances {
|
|
terrain: view_distances
|
|
.terrain
|
|
.clamp(1, MAX_SELECTABLE_VIEW_DISTANCE),
|
|
entity: view_distances.entity.max(1),
|
|
};
|
|
self.view_distance = Some(view_distances.terrain);
|
|
view_distances
|
|
}
|
|
|
|
pub fn set_lod_distance(&mut self, lod_distance: u32) {
|
|
let lod_distance = lod_distance.clamp(0, 1000) as f32 / lod::ZONE_SIZE as f32;
|
|
self.lod_distance = lod_distance;
|
|
}
|
|
|
|
pub fn set_flashing_lights_enabled(&mut self, flashing_lights_enabled: bool) {
|
|
self.flashing_lights_enabled = flashing_lights_enabled;
|
|
}
|
|
|
|
pub fn use_slot(&mut self, slot: Slot) {
|
|
self.control_action(ControlAction::InventoryAction(InventoryAction::Use(slot)))
|
|
}
|
|
|
|
pub fn swap_slots(&mut self, a: Slot, b: Slot) {
|
|
match (a, b) {
|
|
(Slot::Equip(equip), slot) | (slot, Slot::Equip(equip)) => self.control_action(
|
|
ControlAction::InventoryAction(InventoryAction::Swap(equip, slot)),
|
|
),
|
|
(Slot::Inventory(inv1), Slot::Inventory(inv2)) => {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::Swap(inv1, inv2),
|
|
)))
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn drop_slot(&mut self, slot: Slot) {
|
|
match slot {
|
|
Slot::Equip(equip) => {
|
|
self.control_action(ControlAction::InventoryAction(InventoryAction::Drop(equip)))
|
|
},
|
|
Slot::Inventory(inv) => self.send_msg(ClientGeneral::ControlEvent(
|
|
ControlEvent::InventoryEvent(InventoryEvent::Drop(inv)),
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn sort_inventory(&mut self) {
|
|
self.control_action(ControlAction::InventoryAction(InventoryAction::Sort));
|
|
}
|
|
|
|
pub fn perform_trade_action(&mut self, action: TradeAction) {
|
|
if let Some((id, _, _)) = self.pending_trade {
|
|
if let TradeAction::Decline = action {
|
|
self.pending_trade.take();
|
|
}
|
|
self.send_msg(ClientGeneral::ControlEvent(
|
|
ControlEvent::PerformTradeAction(id, action),
|
|
));
|
|
}
|
|
}
|
|
|
|
pub fn is_dead(&self) -> bool { self.current::<comp::Health>().map_or(false, |h| h.is_dead) }
|
|
|
|
pub fn is_gliding(&self) -> bool {
|
|
self.current::<CharacterState>()
|
|
.map_or(false, |cs| matches!(cs, CharacterState::Glide(_)))
|
|
}
|
|
|
|
pub fn split_swap_slots(&mut self, a: Slot, b: Slot) {
|
|
match (a, b) {
|
|
(Slot::Equip(equip), slot) | (slot, Slot::Equip(equip)) => self.control_action(
|
|
ControlAction::InventoryAction(InventoryAction::Swap(equip, slot)),
|
|
),
|
|
(Slot::Inventory(inv1), Slot::Inventory(inv2)) => {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::SplitSwap(inv1, inv2),
|
|
)))
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn split_drop_slot(&mut self, slot: Slot) {
|
|
match slot {
|
|
Slot::Equip(equip) => {
|
|
self.control_action(ControlAction::InventoryAction(InventoryAction::Drop(equip)))
|
|
},
|
|
Slot::Inventory(inv) => self.send_msg(ClientGeneral::ControlEvent(
|
|
ControlEvent::InventoryEvent(InventoryEvent::SplitDrop(inv)),
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn pick_up(&mut self, entity: EcsEntity) {
|
|
// Get the health component from the entity
|
|
|
|
if let Some(uid) = self.state.read_component_copied(entity) {
|
|
// If we're dead, exit before sending the message
|
|
if self.is_dead() {
|
|
return;
|
|
}
|
|
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::Pickup(uid),
|
|
)));
|
|
}
|
|
}
|
|
|
|
pub fn npc_interact(&mut self, npc_entity: EcsEntity, subject: Subject) {
|
|
// If we're dead, exit before sending message
|
|
if self.is_dead() {
|
|
return;
|
|
}
|
|
|
|
if let Some(uid) = self.state.read_component_copied(npc_entity) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::Interact(
|
|
uid, subject,
|
|
)));
|
|
}
|
|
}
|
|
|
|
pub fn player_list(&self) -> &HashMap<Uid, PlayerInfo> { &self.player_list }
|
|
|
|
pub fn character_list(&self) -> &CharacterList { &self.character_list }
|
|
|
|
pub fn server_info(&self) -> &ServerInfo { &self.server_info }
|
|
|
|
pub fn server_description(&self) -> &ServerDescription { &self.server_description }
|
|
|
|
pub fn world_data(&self) -> &WorldData { &self.world_data }
|
|
|
|
pub fn recipe_book(&self) -> &RecipeBook { &self.recipe_book }
|
|
|
|
pub fn component_recipe_book(&self) -> &ComponentRecipeBook { &self.component_recipe_book }
|
|
|
|
pub fn repair_recipe_book(&self) -> &RepairRecipeBook { &self.repair_recipe_book }
|
|
|
|
pub fn available_recipes(&self) -> &HashMap<String, Option<SpriteKind>> {
|
|
&self.available_recipes
|
|
}
|
|
|
|
pub fn lod_zones(&self) -> &HashMap<Vec2<i32>, lod::Zone> { &self.lod_zones }
|
|
|
|
/// Set the fallback position used for loading LoD zones when the client
|
|
/// entity does not have a position.
|
|
pub fn set_lod_pos_fallback(&mut self, pos: Vec2<f32>) { self.lod_pos_fallback = Some(pos); }
|
|
|
|
/// Returns whether the specified recipe can be crafted and the sprite, if
|
|
/// any, that is required to do so.
|
|
pub fn can_craft_recipe(&self, recipe: &str, amount: u32) -> (bool, Option<SpriteKind>) {
|
|
self.recipe_book
|
|
.get(recipe)
|
|
.zip(self.inventories().get(self.entity()))
|
|
.map(|(recipe, inv)| {
|
|
(
|
|
recipe.inventory_contains_ingredients(inv, amount).is_ok(),
|
|
recipe.craft_sprite,
|
|
)
|
|
})
|
|
.unwrap_or((false, None))
|
|
}
|
|
|
|
pub fn craft_recipe(
|
|
&mut self,
|
|
recipe: &str,
|
|
slots: Vec<(u32, InvSlotId)>,
|
|
craft_sprite: Option<(VolumePos, SpriteKind)>,
|
|
amount: u32,
|
|
) -> bool {
|
|
let (can_craft, required_sprite) = self.can_craft_recipe(recipe, amount);
|
|
let has_sprite = required_sprite.map_or(true, |s| Some(s) == craft_sprite.map(|(_, s)| s));
|
|
if can_craft && has_sprite {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::CraftRecipe {
|
|
craft_event: CraftEvent::Simple {
|
|
recipe: recipe.to_string(),
|
|
slots,
|
|
amount,
|
|
},
|
|
craft_sprite: craft_sprite.map(|(pos, _)| pos),
|
|
},
|
|
)));
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Checks if the item in the given slot can be salvaged.
|
|
pub fn can_salvage_item(&self, slot: InvSlotId) -> bool {
|
|
self.inventories()
|
|
.get(self.entity())
|
|
.and_then(|inv| inv.get(slot))
|
|
.map_or(false, |item| item.is_salvageable())
|
|
}
|
|
|
|
/// Salvage the item in the given inventory slot. `salvage_pos` should be
|
|
/// the location of a relevant crafting station within range of the player.
|
|
pub fn salvage_item(&mut self, slot: InvSlotId, salvage_pos: VolumePos) -> bool {
|
|
let is_salvageable = self.can_salvage_item(slot);
|
|
if is_salvageable {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::CraftRecipe {
|
|
craft_event: CraftEvent::Salvage(slot),
|
|
craft_sprite: Some(salvage_pos),
|
|
},
|
|
)));
|
|
}
|
|
is_salvageable
|
|
}
|
|
|
|
/// Crafts modular weapon from components in the provided slots.
|
|
/// `sprite_pos` should be the location of the necessary crafting station in
|
|
/// range of the player.
|
|
/// Returns whether or not the networking event was sent (which is based on
|
|
/// whether the player has two modular components in the provided slots)
|
|
pub fn craft_modular_weapon(
|
|
&mut self,
|
|
primary_component: InvSlotId,
|
|
secondary_component: InvSlotId,
|
|
sprite_pos: Option<VolumePos>,
|
|
) -> bool {
|
|
let inventories = self.inventories();
|
|
let inventory = inventories.get(self.entity());
|
|
|
|
enum ModKind {
|
|
Primary,
|
|
Secondary,
|
|
}
|
|
|
|
// Closure to get inner modular component info from item in a given slot
|
|
let mod_kind = |slot| match inventory
|
|
.and_then(|inv| inv.get(slot).map(|item| item.kind()))
|
|
.as_deref()
|
|
{
|
|
Some(ItemKind::ModularComponent(modular::ModularComponent::ToolPrimaryComponent {
|
|
..
|
|
})) => Some(ModKind::Primary),
|
|
Some(ItemKind::ModularComponent(
|
|
modular::ModularComponent::ToolSecondaryComponent { .. },
|
|
)) => Some(ModKind::Secondary),
|
|
_ => None,
|
|
};
|
|
|
|
if let (Some(ModKind::Primary), Some(ModKind::Secondary)) =
|
|
(mod_kind(primary_component), mod_kind(secondary_component))
|
|
{
|
|
drop(inventories);
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::CraftRecipe {
|
|
craft_event: CraftEvent::ModularWeapon {
|
|
primary_component,
|
|
secondary_component,
|
|
},
|
|
craft_sprite: sprite_pos,
|
|
},
|
|
)));
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn craft_modular_weapon_component(
|
|
&mut self,
|
|
toolkind: tool::ToolKind,
|
|
material: InvSlotId,
|
|
modifier: Option<InvSlotId>,
|
|
slots: Vec<(u32, InvSlotId)>,
|
|
sprite_pos: Option<VolumePos>,
|
|
) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::CraftRecipe {
|
|
craft_event: CraftEvent::ModularWeaponPrimaryComponent {
|
|
toolkind,
|
|
material,
|
|
modifier,
|
|
slots,
|
|
},
|
|
craft_sprite: sprite_pos,
|
|
},
|
|
)));
|
|
}
|
|
|
|
/// Repairs the item in the given inventory slot. `sprite_pos` should be
|
|
/// the location of a relevant crafting station within range of the player.
|
|
pub fn repair_item(
|
|
&mut self,
|
|
item: Slot,
|
|
slots: Vec<(u32, InvSlotId)>,
|
|
sprite_pos: VolumePos,
|
|
) -> bool {
|
|
let is_repairable = {
|
|
let inventories = self.inventories();
|
|
let inventory = inventories.get(self.entity());
|
|
inventory.map_or(false, |inv| {
|
|
if let Some(item) = match item {
|
|
Slot::Equip(equip_slot) => inv.equipped(equip_slot),
|
|
Slot::Inventory(invslot) => inv.get(invslot),
|
|
} {
|
|
item.has_durability()
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
};
|
|
if is_repairable {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
|
|
InventoryEvent::CraftRecipe {
|
|
craft_event: CraftEvent::Repair { item, slots },
|
|
craft_sprite: Some(sprite_pos),
|
|
},
|
|
)));
|
|
}
|
|
is_repairable
|
|
}
|
|
|
|
fn update_available_recipes(&mut self) {
|
|
self.available_recipes = self
|
|
.recipe_book
|
|
.iter()
|
|
.map(|(name, _)| name.clone())
|
|
.filter_map(|name| {
|
|
let (can_craft, required_sprite) = self.can_craft_recipe(&name, 1);
|
|
if can_craft {
|
|
Some((name, required_sprite))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
/// Unstable, likely to be removed in a future release
|
|
pub fn sites(&self) -> &HashMap<SiteId, SiteInfoRich> { &self.sites }
|
|
|
|
pub fn possible_starting_sites(&self) -> &[SiteId] { &self.possible_starting_sites }
|
|
|
|
/// Unstable, likely to be removed in a future release
|
|
pub fn pois(&self) -> &Vec<PoiInfo> { &self.pois }
|
|
|
|
pub fn sites_mut(&mut self) -> &mut HashMap<SiteId, SiteInfoRich> { &mut self.sites }
|
|
|
|
pub fn enable_lantern(&mut self) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::EnableLantern));
|
|
}
|
|
|
|
pub fn disable_lantern(&mut self) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::DisableLantern));
|
|
}
|
|
|
|
pub fn toggle_sprite_light(&mut self, pos: VolumePos, enable: bool) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::BlockInteraction(
|
|
pos,
|
|
BlockInteraction::ToggleLight(enable),
|
|
)));
|
|
}
|
|
|
|
pub fn remove_buff(&mut self, buff_id: BuffKind) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::RemoveBuff(
|
|
buff_id,
|
|
)));
|
|
}
|
|
|
|
pub fn leave_stance(&mut self) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::LeaveStance));
|
|
}
|
|
|
|
pub fn unlock_skill(&mut self, skill: Skill) {
|
|
self.send_msg(ClientGeneral::UnlockSkill(skill));
|
|
}
|
|
|
|
pub fn max_group_size(&self) -> u32 { self.max_group_size }
|
|
|
|
pub fn invite(&self) -> Option<(Uid, Instant, Duration, InviteKind)> { self.invite }
|
|
|
|
pub fn group_info(&self) -> Option<(String, Uid)> {
|
|
self.group_leader.map(|l| ("Group".into(), l)) // TODO
|
|
}
|
|
|
|
pub fn group_members(&self) -> &HashMap<Uid, group::Role> { &self.group_members }
|
|
|
|
pub fn pending_invites(&self) -> &HashSet<Uid> { &self.pending_invites }
|
|
|
|
pub fn pending_trade(&self) -> &Option<(TradeId, PendingTrade, Option<SitePrices>)> {
|
|
&self.pending_trade
|
|
}
|
|
|
|
pub fn is_trading(&self) -> bool { self.pending_trade.is_some() }
|
|
|
|
pub fn send_invite(&mut self, invitee: Uid, kind: InviteKind) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InitiateInvite(
|
|
invitee, kind,
|
|
)))
|
|
}
|
|
|
|
pub fn accept_invite(&mut self) {
|
|
// Clear invite
|
|
self.invite.take();
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InviteResponse(
|
|
InviteResponse::Accept,
|
|
)));
|
|
}
|
|
|
|
pub fn decline_invite(&mut self) {
|
|
// Clear invite
|
|
self.invite.take();
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InviteResponse(
|
|
InviteResponse::Decline,
|
|
)));
|
|
}
|
|
|
|
pub fn leave_group(&mut self) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::GroupManip(
|
|
GroupManip::Leave,
|
|
)));
|
|
}
|
|
|
|
pub fn kick_from_group(&mut self, uid: Uid) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::GroupManip(
|
|
GroupManip::Kick(uid),
|
|
)));
|
|
}
|
|
|
|
pub fn assign_group_leader(&mut self, uid: Uid) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::GroupManip(
|
|
GroupManip::AssignLeader(uid),
|
|
)));
|
|
}
|
|
|
|
pub fn is_riding(&self) -> bool {
|
|
self.state
|
|
.ecs()
|
|
.read_storage::<Is<Rider>>()
|
|
.get(self.entity())
|
|
.is_some()
|
|
|| self
|
|
.state
|
|
.ecs()
|
|
.read_storage::<Is<VolumeRider>>()
|
|
.get(self.entity())
|
|
.is_some()
|
|
}
|
|
|
|
pub fn is_lantern_enabled(&self) -> bool {
|
|
self.state
|
|
.ecs()
|
|
.read_storage::<comp::LightEmitter>()
|
|
.get(self.entity())
|
|
.is_some()
|
|
}
|
|
|
|
pub fn mount(&mut self, entity: EcsEntity) {
|
|
if let Some(uid) = self.state.read_component_copied(entity) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::Mount(uid)));
|
|
}
|
|
}
|
|
|
|
/// Mount a block at a `VolumePos`.
|
|
pub fn mount_volume(&mut self, volume_pos: VolumePos) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::MountVolume(
|
|
volume_pos,
|
|
)));
|
|
}
|
|
|
|
pub fn unmount(&mut self) { self.send_msg(ClientGeneral::ControlEvent(ControlEvent::Unmount)); }
|
|
|
|
pub fn set_pet_stay(&mut self, entity: EcsEntity, stay: bool) {
|
|
if let Some(uid) = self.state.read_component_copied(entity) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::SetPetStay(
|
|
uid, stay,
|
|
)));
|
|
}
|
|
}
|
|
|
|
pub fn respawn(&mut self) {
|
|
if self
|
|
.state
|
|
.ecs()
|
|
.read_storage::<comp::Health>()
|
|
.get(self.entity())
|
|
.map_or(false, |h| h.is_dead)
|
|
{
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::Respawn));
|
|
}
|
|
}
|
|
|
|
pub fn map_marker_event(&mut self, event: MapMarkerChange) {
|
|
self.send_msg(ClientGeneral::UpdateMapMarker(event));
|
|
}
|
|
|
|
/// Set the current position to spectate, returns true if the client's
|
|
/// player has a Pos component to write to.
|
|
pub fn spectate_position(&mut self, pos: Vec3<f32>) -> bool {
|
|
let write = if let Some(position) = self
|
|
.state
|
|
.ecs()
|
|
.write_storage::<comp::Pos>()
|
|
.get_mut(self.entity())
|
|
{
|
|
position.0 = pos;
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
if write {
|
|
self.send_msg(ClientGeneral::SpectatePosition(pos));
|
|
}
|
|
write
|
|
}
|
|
|
|
/// Checks whether a player can swap their weapon+ability `Loadout` settings
|
|
/// and sends the `ControlAction` event that signals to do the swap.
|
|
pub fn swap_loadout(&mut self) { self.control_action(ControlAction::SwapEquippedWeapons) }
|
|
|
|
/// Determine whether the player is wielding, if they're even capable of
|
|
/// being in a wield state.
|
|
pub fn is_wielding(&self) -> Option<bool> {
|
|
self.state
|
|
.ecs()
|
|
.read_storage::<CharacterState>()
|
|
.get(self.entity())
|
|
.map(|cs| cs.is_wield())
|
|
}
|
|
|
|
pub fn toggle_wield(&mut self) {
|
|
match self.is_wielding() {
|
|
Some(true) => self.control_action(ControlAction::Unwield),
|
|
Some(false) => self.control_action(ControlAction::Wield),
|
|
None => warn!("Can't toggle wield, client entity doesn't have a `CharacterState`"),
|
|
}
|
|
}
|
|
|
|
pub fn toggle_sit(&mut self) {
|
|
let is_sitting = self
|
|
.state
|
|
.ecs()
|
|
.read_storage::<CharacterState>()
|
|
.get(self.entity())
|
|
.map(|cs| matches!(cs, CharacterState::Sit));
|
|
|
|
match is_sitting {
|
|
Some(true) => self.control_action(ControlAction::Stand),
|
|
Some(false) => self.control_action(ControlAction::Sit),
|
|
None => warn!("Can't toggle sit, client entity doesn't have a `CharacterState`"),
|
|
}
|
|
}
|
|
|
|
pub fn toggle_dance(&mut self) {
|
|
let is_dancing = self
|
|
.state
|
|
.ecs()
|
|
.read_storage::<CharacterState>()
|
|
.get(self.entity())
|
|
.map(|cs| matches!(cs, CharacterState::Dance));
|
|
|
|
match is_dancing {
|
|
Some(true) => self.control_action(ControlAction::Stand),
|
|
Some(false) => self.control_action(ControlAction::Dance),
|
|
None => warn!("Can't toggle dance, client entity doesn't have a `CharacterState`"),
|
|
}
|
|
}
|
|
|
|
pub fn utter(&mut self, kind: UtteranceKind) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::Utterance(kind)));
|
|
}
|
|
|
|
pub fn toggle_sneak(&mut self) {
|
|
let is_sneaking = self
|
|
.state
|
|
.ecs()
|
|
.read_storage::<CharacterState>()
|
|
.get(self.entity())
|
|
.map(CharacterState::is_stealthy);
|
|
|
|
match is_sneaking {
|
|
Some(true) => self.control_action(ControlAction::Stand),
|
|
Some(false) => self.control_action(ControlAction::Sneak),
|
|
None => warn!("Can't toggle sneak, client entity doesn't have a `CharacterState`"),
|
|
}
|
|
}
|
|
|
|
pub fn toggle_glide(&mut self) {
|
|
let using_glider = self
|
|
.state
|
|
.ecs()
|
|
.read_storage::<CharacterState>()
|
|
.get(self.entity())
|
|
.map(|cs| matches!(cs, CharacterState::GlideWield(_) | CharacterState::Glide(_)));
|
|
|
|
match using_glider {
|
|
Some(true) => self.control_action(ControlAction::Unwield),
|
|
Some(false) => self.control_action(ControlAction::GlideWield),
|
|
None => warn!("Can't toggle glide, client entity doesn't have a `CharacterState`"),
|
|
}
|
|
}
|
|
|
|
pub fn handle_input(
|
|
&mut self,
|
|
input: InputKind,
|
|
pressed: bool,
|
|
select_pos: Option<Vec3<f32>>,
|
|
target_entity: Option<EcsEntity>,
|
|
) {
|
|
if pressed {
|
|
self.control_action(ControlAction::StartInput {
|
|
input,
|
|
target_entity: target_entity.and_then(|e| self.state.read_component_copied(e)),
|
|
select_pos,
|
|
});
|
|
} else {
|
|
self.control_action(ControlAction::CancelInput(input));
|
|
}
|
|
}
|
|
|
|
pub fn activate_portal(&mut self, portal: Uid) {
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::ActivatePortal(
|
|
portal,
|
|
)));
|
|
}
|
|
|
|
fn control_action(&mut self, control_action: ControlAction) {
|
|
if let Some(controller) = self
|
|
.state
|
|
.ecs()
|
|
.write_storage::<Controller>()
|
|
.get_mut(self.entity())
|
|
{
|
|
controller.push_action(control_action);
|
|
}
|
|
self.send_msg(ClientGeneral::ControlAction(control_action));
|
|
}
|
|
|
|
pub fn view_distance(&self) -> Option<u32> { self.view_distance }
|
|
|
|
pub fn server_view_distance_limit(&self) -> Option<u32> { self.server_view_distance_limit }
|
|
|
|
pub fn loaded_distance(&self) -> f32 { self.loaded_distance }
|
|
|
|
pub fn position(&self) -> Option<Vec3<f32>> {
|
|
self.state
|
|
.read_storage::<comp::Pos>()
|
|
.get(self.entity())
|
|
.map(|v| v.0)
|
|
}
|
|
|
|
/// Returns Weather::default if no player position exists.
|
|
pub fn weather_at_player(&self) -> Weather {
|
|
self.position()
|
|
.map(|wpos| self.state.weather_at(wpos.xy()))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn current_chunk(&self) -> Option<Arc<TerrainChunk>> {
|
|
let chunk_pos = Vec2::from(self.position()?)
|
|
.map2(TerrainChunkSize::RECT_SIZE, |e: f32, sz| {
|
|
(e as u32).div_euclid(sz) as i32
|
|
});
|
|
|
|
self.state.terrain().get_key_arc(chunk_pos).cloned()
|
|
}
|
|
|
|
pub fn current<C: Component>(&self) -> Option<C>
|
|
where
|
|
C: Clone,
|
|
{
|
|
self.state.read_storage::<C>().get(self.entity()).cloned()
|
|
}
|
|
|
|
pub fn current_biome(&self) -> BiomeKind {
|
|
match self.current_chunk() {
|
|
Some(chunk) => chunk.meta().biome(),
|
|
_ => BiomeKind::Void,
|
|
}
|
|
}
|
|
|
|
pub fn current_site(&self) -> SiteKindMeta {
|
|
let mut player_alt = 0.0;
|
|
if let Some(position) = self.current::<comp::Pos>() {
|
|
player_alt = position.0.z;
|
|
}
|
|
//let mut contains_cave = false;
|
|
let mut terrain_alt = 0.0;
|
|
let mut site = None;
|
|
if let Some(chunk) = self.current_chunk() {
|
|
terrain_alt = chunk.meta().alt();
|
|
//contains_cave = chunk.meta().contains_cave();
|
|
site = chunk.meta().site();
|
|
}
|
|
if player_alt < terrain_alt - 40.0 {
|
|
if let Some(SiteKindMeta::Dungeon(dungeon)) = site {
|
|
SiteKindMeta::Dungeon(dungeon)
|
|
} else {
|
|
SiteKindMeta::Cave
|
|
}
|
|
} else if matches!(site, Some(SiteKindMeta::Dungeon(DungeonKindMeta::Old))) {
|
|
// If the player is in a dungeon chunk but aboveground, pass Void instead
|
|
SiteKindMeta::Void
|
|
} else {
|
|
site.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
pub fn request_site_economy(&mut self, id: SiteId) {
|
|
self.send_msg(ClientGeneral::RequestSiteInfo(id))
|
|
}
|
|
|
|
pub fn inventories(&self) -> ReadStorage<comp::Inventory> { self.state.read_storage() }
|
|
|
|
/// Send a chat message to the server.
|
|
pub fn send_chat(&mut self, message: String) { self.send_msg(ClientGeneral::ChatMsg(message)); }
|
|
|
|
/// Send a command to the server.
|
|
pub fn send_command(&mut self, name: String, args: Vec<String>) {
|
|
self.send_msg(ClientGeneral::Command(name, args));
|
|
}
|
|
|
|
/// Remove all cached terrain
|
|
pub fn clear_terrain(&mut self) {
|
|
self.state.clear_terrain();
|
|
self.pending_chunks.clear();
|
|
}
|
|
|
|
pub fn place_block(&mut self, pos: Vec3<i32>, block: Block) {
|
|
self.send_msg(ClientGeneral::PlaceBlock(pos, block));
|
|
}
|
|
|
|
pub fn remove_block(&mut self, pos: Vec3<i32>) {
|
|
self.send_msg(ClientGeneral::BreakBlock(pos));
|
|
}
|
|
|
|
pub fn collect_block(&mut self, pos: Vec3<i32>) {
|
|
self.control_action(ControlAction::InventoryAction(InventoryAction::Collect(
|
|
pos,
|
|
)));
|
|
}
|
|
|
|
pub fn change_ability(&mut self, slot: usize, new_ability: comp::ability::AuxiliaryAbility) {
|
|
let auxiliary_key = self
|
|
.inventories()
|
|
.get(self.entity())
|
|
.map_or((None, None), |inv| {
|
|
let tool_kind = |slot| {
|
|
inv.equipped(slot).and_then(|item| match &*item.kind() {
|
|
ItemKind::Tool(tool) => Some(tool.kind),
|
|
_ => None,
|
|
})
|
|
};
|
|
|
|
(
|
|
tool_kind(EquipSlot::ActiveMainhand),
|
|
tool_kind(EquipSlot::ActiveOffhand),
|
|
)
|
|
});
|
|
|
|
self.send_msg(ClientGeneral::ControlEvent(ControlEvent::ChangeAbility {
|
|
slot,
|
|
auxiliary_key,
|
|
new_ability,
|
|
}))
|
|
}
|
|
|
|
/// Execute a single client tick, handle input and update the game state by
|
|
/// the given duration.
|
|
pub fn tick(&mut self, inputs: ControllerInputs, dt: Duration) -> Result<Vec<Event>, Error> {
|
|
span!(_guard, "tick", "Client::tick");
|
|
// This tick function is the centre of the Veloren universe. Most client-side
|
|
// things are managed from here, and as such it's important that it
|
|
// stays organised. Please consult the core developers before making
|
|
// significant changes to this code. Here is the approximate order of
|
|
// things. Please update it as this code changes.
|
|
//
|
|
// 1) Collect input from the frontend, apply input effects to the state of the
|
|
// game
|
|
// 2) Handle messages from the server
|
|
// 3) Go through any events (timer-driven or otherwise) that need handling and
|
|
// apply them to the state of the game
|
|
// 4) Perform a single LocalState tick (i.e: update the world and entities in
|
|
// the world)
|
|
// 5) Go through the terrain update queue and apply all changes to the terrain
|
|
// 6) Sync information to the server
|
|
// 7) Finish the tick, passing actions of the main thread back to the frontend
|
|
|
|
// 1) Handle input from frontend.
|
|
// Pass character actions from frontend input to the player's entity.
|
|
if self.presence.is_some() {
|
|
prof_span!("handle and send inputs");
|
|
if let Err(e) = self
|
|
.state
|
|
.ecs()
|
|
.write_storage::<Controller>()
|
|
.entry(self.entity())
|
|
.map(|entry| {
|
|
entry
|
|
.or_insert_with(|| Controller {
|
|
inputs: inputs.clone(),
|
|
queued_inputs: BTreeMap::new(),
|
|
events: Vec::new(),
|
|
actions: Vec::new(),
|
|
})
|
|
.inputs = inputs.clone();
|
|
})
|
|
{
|
|
let entry = self.entity();
|
|
error!(
|
|
?e,
|
|
?entry,
|
|
"Couldn't access controller component on client entity"
|
|
);
|
|
}
|
|
self.send_msg_err(ClientGeneral::ControllerInputs(Box::new(inputs)))?;
|
|
}
|
|
|
|
// 2) Build up a list of events for this frame, to be passed to the frontend.
|
|
let mut frontend_events = Vec::new();
|
|
|
|
// Prepare for new events
|
|
{
|
|
prof_span!("Last<CharacterState> comps update");
|
|
let ecs = self.state.ecs();
|
|
let mut last_character_states = ecs.write_storage::<comp::Last<CharacterState>>();
|
|
for (entity, _, character_state) in (
|
|
&ecs.entities(),
|
|
&ecs.read_storage::<comp::Body>(),
|
|
&ecs.read_storage::<CharacterState>(),
|
|
)
|
|
.join()
|
|
{
|
|
if let Some(l) = last_character_states
|
|
.entry(entity)
|
|
.ok()
|
|
.map(|l| l.or_insert_with(|| comp::Last(character_state.clone())))
|
|
// TODO: since this just updates when the variant changes we should
|
|
// just store the variant to avoid the clone overhead
|
|
.filter(|l| !character_state.same_variant(&l.0))
|
|
{
|
|
*l = comp::Last(character_state.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle new messages from the server.
|
|
frontend_events.append(&mut self.handle_new_messages()?);
|
|
|
|
// 3) Update client local data
|
|
// Check if the invite has timed out and remove if so
|
|
if self
|
|
.invite
|
|
.map_or(false, |(_, timeout, dur, _)| timeout.elapsed() > dur)
|
|
{
|
|
self.invite = None;
|
|
}
|
|
|
|
// Lerp the clientside weather.
|
|
self.weather.update(&mut self.state.weather_grid_mut());
|
|
|
|
if let Some(target_tod) = self.target_time_of_day {
|
|
let mut tod = self.state.ecs_mut().write_resource::<TimeOfDay>();
|
|
tod.0 = target_tod.0;
|
|
self.target_time_of_day = None;
|
|
}
|
|
|
|
// 4) Tick the client's LocalState
|
|
self.state.tick(
|
|
Duration::from_secs_f64(dt.as_secs_f64() * self.dt_adjustment),
|
|
true,
|
|
None,
|
|
&self.connected_server_constants,
|
|
|_, _| {},
|
|
);
|
|
// TODO: avoid emitting these in the first place
|
|
let _ = self
|
|
.state
|
|
.ecs()
|
|
.fetch::<EventBus<common::event::ServerEvent>>()
|
|
.recv_all();
|
|
// TODO: avoid emitting these in the first place OR actually use outcomes
|
|
// generated locally on the client (if they can be deduplicated from
|
|
// ones that the server generates or if the client can reliably generate
|
|
// them (e.g. syncing skipping character states past certain
|
|
// stages might skip points where outcomes are generated, however we might not
|
|
// care about this?) and the server doesn't need to send them)
|
|
let _ = self.state.ecs().fetch::<EventBus<Outcome>>().recv_all();
|
|
|
|
// 5) Terrain
|
|
self.tick_terrain()?;
|
|
|
|
// Send a ping to the server once every second
|
|
if self.state.get_program_time() - self.last_server_ping > 1. {
|
|
self.send_msg_err(PingMsg::Ping)?;
|
|
self.last_server_ping = self.state.get_program_time();
|
|
}
|
|
|
|
// 6) Update the server about the player's physics attributes.
|
|
if self.presence.is_some() {
|
|
if let (Some(pos), Some(vel), Some(ori)) = (
|
|
self.state.read_storage().get(self.entity()).cloned(),
|
|
self.state.read_storage().get(self.entity()).cloned(),
|
|
self.state.read_storage().get(self.entity()).cloned(),
|
|
) {
|
|
self.in_game_stream.send(ClientGeneral::PlayerPhysics {
|
|
pos,
|
|
vel,
|
|
ori,
|
|
force_counter: self.force_update_counter,
|
|
})?;
|
|
}
|
|
}
|
|
|
|
/*
|
|
// Output debug metrics
|
|
if log_enabled!(Level::Info) && self.tick % 600 == 0 {
|
|
let metrics = self
|
|
.state
|
|
.terrain()
|
|
.iter()
|
|
.fold(ChonkMetrics::default(), |a, (_, c)| a + c.get_metrics());
|
|
info!("{:?}", metrics);
|
|
}
|
|
*/
|
|
|
|
// 7) Finish the tick, pass control back to the frontend.
|
|
self.tick += 1;
|
|
Ok(frontend_events)
|
|
}
|
|
|
|
/// Clean up the client after a tick.
|
|
pub fn cleanup(&mut self) {
|
|
// Cleanup the local state
|
|
self.state.cleanup();
|
|
}
|
|
|
|
/// Handles terrain addition and removal.
|
|
///
|
|
/// Removes old terrain chunks outside the view distance.
|
|
/// Sends requests for missing chunks within the view distance.
|
|
fn tick_terrain(&mut self) -> Result<(), Error> {
|
|
let pos = self
|
|
.state
|
|
.read_storage::<comp::Pos>()
|
|
.get(self.entity())
|
|
.cloned();
|
|
if let (Some(pos), Some(view_distance)) = (pos, self.view_distance) {
|
|
prof_span!("terrain");
|
|
let chunk_pos = self.state.terrain().pos_key(pos.0.map(|e| e as i32));
|
|
|
|
// Remove chunks that are too far from the player.
|
|
let mut chunks_to_remove = Vec::new();
|
|
self.state.terrain().iter().for_each(|(key, _)| {
|
|
// Subtract 2 from the offset before computing squared magnitude
|
|
// 1 for the chunks needed bordering other chunks for meshing
|
|
// 1 as a buffer so that if the player moves back in that direction the chunks
|
|
// don't need to be reloaded
|
|
// Take the minimum of the adjusted difference vs the view_distance + 1 to
|
|
// prevent magnitude_squared from overflowing
|
|
|
|
if (chunk_pos - key)
|
|
.map(|e: i32| (e.unsigned_abs()).saturating_sub(2).min(view_distance + 1))
|
|
.magnitude_squared()
|
|
> view_distance.pow(2)
|
|
{
|
|
chunks_to_remove.push(key);
|
|
}
|
|
});
|
|
for key in chunks_to_remove {
|
|
self.state.remove_chunk(key);
|
|
}
|
|
|
|
let mut current_tick_send_chunk_requests = 0;
|
|
// Request chunks from the server.
|
|
self.loaded_distance = ((view_distance * TerrainChunkSize::RECT_SIZE.x) as f32).powi(2);
|
|
// +1 so we can find a chunk that's outside the vd for better fog
|
|
for dist in 0..view_distance as i32 + 1 {
|
|
// Only iterate through chunks that need to be loaded for circular vd
|
|
// The (dist - 2) explained:
|
|
// -0.5 because a chunk is visible if its corner is within the view distance
|
|
// -0.5 for being able to move to the corner of the current chunk
|
|
// -1 because chunks are not meshed if they don't have all their neighbors
|
|
// (notice also that view_distance is decreased by 1)
|
|
// (this subtraction on vd is omitted elsewhere in order to provide
|
|
// a buffer layer of loaded chunks)
|
|
let top = if 2 * (dist - 2).max(0).pow(2) > (view_distance - 1).pow(2) as i32 {
|
|
((view_distance - 1).pow(2) as f32 - (dist - 2).pow(2) as f32)
|
|
.sqrt()
|
|
.round() as i32
|
|
+ 1
|
|
} else {
|
|
dist
|
|
};
|
|
|
|
let mut skip_mode = false;
|
|
for i in -top..top + 1 {
|
|
let keys = [
|
|
chunk_pos + Vec2::new(dist, i),
|
|
chunk_pos + Vec2::new(i, dist),
|
|
chunk_pos + Vec2::new(-dist, i),
|
|
chunk_pos + Vec2::new(i, -dist),
|
|
];
|
|
|
|
for key in keys.iter() {
|
|
let dist_to_player = (TerrainGrid::key_chunk(*key).map(|x| x as f32)
|
|
+ TerrainChunkSize::RECT_SIZE.map(|x| x as f32) / 2.0)
|
|
.distance_squared(pos.0.into());
|
|
|
|
let terrain = self.state.terrain();
|
|
if let Some(chunk) = terrain.get_key_arc(*key) {
|
|
if !skip_mode && !terrain.contains_key_real(*key) {
|
|
let chunk = Arc::clone(chunk);
|
|
drop(terrain);
|
|
self.state.insert_chunk(*key, chunk);
|
|
}
|
|
} else {
|
|
drop(terrain);
|
|
if !skip_mode && !self.pending_chunks.contains_key(key) {
|
|
const TOTAL_PENDING_CHUNKS_LIMIT: usize = 12;
|
|
const CURRENT_TICK_PENDING_CHUNKS_LIMIT: usize = 2;
|
|
if self.pending_chunks.len() < TOTAL_PENDING_CHUNKS_LIMIT
|
|
&& current_tick_send_chunk_requests
|
|
< CURRENT_TICK_PENDING_CHUNKS_LIMIT
|
|
{
|
|
self.send_msg_err(ClientGeneral::TerrainChunkRequest {
|
|
key: *key,
|
|
})?;
|
|
current_tick_send_chunk_requests += 1;
|
|
self.pending_chunks.insert(*key, Instant::now());
|
|
} else {
|
|
skip_mode = true;
|
|
}
|
|
}
|
|
|
|
if dist_to_player < self.loaded_distance {
|
|
self.loaded_distance = dist_to_player;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.loaded_distance = self.loaded_distance.sqrt()
|
|
- ((TerrainChunkSize::RECT_SIZE.x as f32 / 2.0).powi(2)
|
|
+ (TerrainChunkSize::RECT_SIZE.y as f32 / 2.0).powi(2))
|
|
.sqrt();
|
|
|
|
// If chunks are taking too long, assume they're no longer pending.
|
|
let now = Instant::now();
|
|
self.pending_chunks
|
|
.retain(|_, created| now.duration_since(*created) < Duration::from_secs(3));
|
|
}
|
|
|
|
if let Some(lod_pos) = pos.map(|p| p.0.xy()).or(self.lod_pos_fallback) {
|
|
// Manage LoD zones
|
|
let lod_zone = lod_pos.map(|e| lod::from_wpos(e as i32));
|
|
|
|
// Request LoD zones that are in range
|
|
if self
|
|
.lod_last_requested
|
|
.map_or(true, |i| i.elapsed() > Duration::from_secs(5))
|
|
{
|
|
if let Some(rpos) = Spiral2d::new()
|
|
.take((1 + self.lod_distance.ceil() as i32 * 2).pow(2) as usize)
|
|
.filter(|rpos| !self.lod_zones.contains_key(&(lod_zone + *rpos)))
|
|
.min_by_key(|rpos| rpos.magnitude_squared())
|
|
.filter(|rpos| {
|
|
rpos.map(|e| e as f32).magnitude() < (self.lod_distance - 0.5).max(0.0)
|
|
})
|
|
{
|
|
self.send_msg_err(ClientGeneral::LodZoneRequest {
|
|
key: lod_zone + rpos,
|
|
})?;
|
|
self.lod_last_requested = Some(Instant::now());
|
|
}
|
|
}
|
|
|
|
// Cull LoD zones out of range
|
|
self.lod_zones.retain(|p, _| {
|
|
(*p - lod_zone).map(|e| e as f32).magnitude_squared() < self.lod_distance.powi(2)
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_server_msg(
|
|
&mut self,
|
|
frontend_events: &mut Vec<Event>,
|
|
msg: ServerGeneral,
|
|
) -> Result<(), Error> {
|
|
prof_span!("handle_server_msg");
|
|
match msg {
|
|
ServerGeneral::Disconnect(reason) => match reason {
|
|
DisconnectReason::Shutdown => return Err(Error::ServerShutdown),
|
|
DisconnectReason::Kicked(reason) => {
|
|
debug!("sending ClientMsg::Terminate because we got kicked");
|
|
frontend_events.push(Event::Kicked(reason));
|
|
self.send_msg_err(ClientGeneral::Terminate)?;
|
|
},
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::Init(list)) => {
|
|
self.player_list = list
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::Add(uid, player_info)) => {
|
|
if let Some(old_player_info) = self.player_list.insert(uid, player_info.clone()) {
|
|
warn!(
|
|
"Received msg to insert {} with uid {} into the player list but there was \
|
|
already an entry for {} with the same uid that was overwritten!",
|
|
player_info.player_alias, uid, old_player_info.player_alias
|
|
);
|
|
}
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::Moderator(uid, moderator)) => {
|
|
if let Some(player_info) = self.player_list.get_mut(&uid) {
|
|
player_info.is_moderator = moderator;
|
|
} else {
|
|
warn!(
|
|
"Received msg to update admin status of uid {}, but they were not in the \
|
|
list.",
|
|
uid
|
|
);
|
|
}
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::SelectedCharacter(
|
|
uid,
|
|
char_info,
|
|
)) => {
|
|
if let Some(player_info) = self.player_list.get_mut(&uid) {
|
|
player_info.character = Some(char_info);
|
|
} else {
|
|
warn!(
|
|
"Received msg to update character info for uid {}, but they were not in \
|
|
the list.",
|
|
uid
|
|
);
|
|
}
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::LevelChange(uid, next_level)) => {
|
|
if let Some(player_info) = self.player_list.get_mut(&uid) {
|
|
player_info.character = match &player_info.character {
|
|
Some(character) => Some(msg::CharacterInfo {
|
|
name: character.name.to_string(),
|
|
}),
|
|
None => {
|
|
warn!(
|
|
"Received msg to update character level info to {} for uid {}, \
|
|
but this player's character is None.",
|
|
next_level, uid
|
|
);
|
|
|
|
None
|
|
},
|
|
};
|
|
}
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::Remove(uid)) => {
|
|
// Instead of removing players, mark them as offline because we need to
|
|
// remember the names of disconnected players in chat.
|
|
//
|
|
// TODO: consider alternatives since this leads to an ever growing list as
|
|
// players log out and in. Keep in mind we might only want to
|
|
// keep only so many messages in chat the history. We could
|
|
// potentially use an ID that's more persistent than the Uid.
|
|
// One of the reasons we don't just store the string of the player name
|
|
// into the message is to make alias changes reflected in older messages.
|
|
|
|
if let Some(player_info) = self.player_list.get_mut(&uid) {
|
|
if player_info.is_online {
|
|
player_info.is_online = false;
|
|
} else {
|
|
warn!(
|
|
"Received msg to remove uid {} from the player list by they were \
|
|
already marked offline",
|
|
uid
|
|
);
|
|
}
|
|
} else {
|
|
warn!(
|
|
"Received msg to remove uid {} from the player list by they weren't in \
|
|
the list!",
|
|
uid
|
|
);
|
|
}
|
|
},
|
|
ServerGeneral::PlayerListUpdate(PlayerListUpdate::Alias(uid, new_name)) => {
|
|
if let Some(player_info) = self.player_list.get_mut(&uid) {
|
|
player_info.player_alias = new_name;
|
|
} else {
|
|
warn!(
|
|
"Received msg to alias player with uid {} to {} but this uid is not in \
|
|
the player list",
|
|
uid, new_name
|
|
);
|
|
}
|
|
},
|
|
ServerGeneral::ChatMsg(m) => frontend_events.push(Event::Chat(m)),
|
|
ServerGeneral::ChatMode(m) => {
|
|
self.chat_mode = m;
|
|
},
|
|
ServerGeneral::SetPlayerEntity(uid) => {
|
|
if let Some(entity) = self.state.ecs().entity_from_uid(uid) {
|
|
let old_player_entity = mem::replace(
|
|
&mut *self.state.ecs_mut().write_resource(),
|
|
PlayerEntity(Some(entity)),
|
|
);
|
|
if let Some(old_entity) = old_player_entity.0 {
|
|
// Transfer controller to the new entity.
|
|
let mut controllers = self.state.ecs().write_storage::<Controller>();
|
|
if let Some(controller) = controllers.remove(old_entity) {
|
|
if let Err(e) = controllers.insert(entity, controller) {
|
|
error!(
|
|
?e,
|
|
"Failed to insert controller when setting new player entity!"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if let Some(presence) = self.presence {
|
|
self.presence = Some(match presence {
|
|
PresenceKind::Spectator => PresenceKind::Spectator,
|
|
PresenceKind::LoadingCharacter(_) => PresenceKind::Possessor,
|
|
PresenceKind::Character(_) => PresenceKind::Possessor,
|
|
PresenceKind::Possessor => PresenceKind::Possessor,
|
|
});
|
|
}
|
|
// Clear pending trade
|
|
self.pending_trade = None;
|
|
} else {
|
|
return Err(Error::Other("Failed to find entity from uid.".into()));
|
|
}
|
|
},
|
|
ServerGeneral::TimeOfDay(time_of_day, calendar, new_time, time_scale) => {
|
|
self.target_time_of_day = Some(time_of_day);
|
|
*self.state.ecs_mut().write_resource() = calendar;
|
|
*self.state.ecs_mut().write_resource() = time_scale;
|
|
let mut time = self.state.ecs_mut().write_resource::<Time>();
|
|
// Avoid side-eye from Einstein
|
|
// If new time from server is at least 5 seconds ahead, replace client time.
|
|
// Otherwise try to slightly twean client time (by 1%) to keep it in line with
|
|
// server time.
|
|
self.dt_adjustment = if new_time.0 > time.0 + 5.0 {
|
|
*time = new_time;
|
|
1.0
|
|
} else if new_time.0 > time.0 {
|
|
1.01
|
|
} else {
|
|
0.99
|
|
};
|
|
},
|
|
ServerGeneral::EntitySync(entity_sync_package) => {
|
|
let uid = self.uid();
|
|
self.state
|
|
.ecs_mut()
|
|
.apply_entity_sync_package(entity_sync_package, uid);
|
|
},
|
|
ServerGeneral::CompSync(comp_sync_package, force_counter) => {
|
|
self.force_update_counter = force_counter;
|
|
self.state
|
|
.ecs_mut()
|
|
.apply_comp_sync_package(comp_sync_package);
|
|
},
|
|
ServerGeneral::CreateEntity(entity_package) => {
|
|
self.state.ecs_mut().apply_entity_package(entity_package);
|
|
},
|
|
ServerGeneral::DeleteEntity(entity_uid) => {
|
|
if self.uid() != Some(entity_uid) {
|
|
self.state
|
|
.ecs_mut()
|
|
.delete_entity_and_clear_uid_mapping(entity_uid);
|
|
}
|
|
},
|
|
ServerGeneral::Notification(n) => {
|
|
frontend_events.push(Event::Notification(n));
|
|
},
|
|
_ => unreachable!("Not a general msg"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_server_in_game_msg(
|
|
&mut self,
|
|
frontend_events: &mut Vec<Event>,
|
|
msg: ServerGeneral,
|
|
) -> Result<(), Error> {
|
|
prof_span!("handle_server_in_game_msg");
|
|
match msg {
|
|
ServerGeneral::GroupUpdate(change_notification) => {
|
|
use comp::group::ChangeNotification::*;
|
|
// Note: we use a hashmap since this would not work with entities outside
|
|
// the view distance
|
|
match change_notification {
|
|
Added(uid, role) => {
|
|
// Check if this is a newly formed group by looking for absence of
|
|
// other non pet group members
|
|
if !matches!(role, group::Role::Pet)
|
|
&& !self
|
|
.group_members
|
|
.values()
|
|
.any(|r| !matches!(r, group::Role::Pet))
|
|
{
|
|
frontend_events
|
|
// TODO: localise
|
|
.push(Event::Chat(comp::ChatType::Meta.into_plain_msg(
|
|
"Type /g or /group to chat with your group members",
|
|
)));
|
|
}
|
|
if let Some(player_info) = self.player_list.get(&uid) {
|
|
frontend_events.push(Event::Chat(
|
|
// TODO: localise
|
|
comp::ChatType::GroupMeta("Group".into()).into_plain_msg(format!(
|
|
"[{}] joined group",
|
|
self.personalize_alias(uid, player_info.player_alias.clone())
|
|
)),
|
|
));
|
|
}
|
|
if self.group_members.insert(uid, role) == Some(role) {
|
|
warn!(
|
|
"Received msg to add uid {} to the group members but they were \
|
|
already there",
|
|
uid
|
|
);
|
|
}
|
|
},
|
|
Removed(uid) => {
|
|
if let Some(player_info) = self.player_list.get(&uid) {
|
|
frontend_events.push(Event::Chat(
|
|
// TODO: localise
|
|
comp::ChatType::GroupMeta("Group".into()).into_plain_msg(format!(
|
|
"[{}] left group",
|
|
self.personalize_alias(uid, player_info.player_alias.clone())
|
|
)),
|
|
));
|
|
frontend_events.push(Event::MapMarker(
|
|
comp::MapMarkerUpdate::GroupMember(uid, MapMarkerChange::Remove),
|
|
));
|
|
}
|
|
if self.group_members.remove(&uid).is_none() {
|
|
warn!(
|
|
"Received msg to remove uid {} from group members but by they \
|
|
weren't in there!",
|
|
uid
|
|
);
|
|
}
|
|
},
|
|
NewLeader(leader) => {
|
|
self.group_leader = Some(leader);
|
|
},
|
|
NewGroup { leader, members } => {
|
|
self.group_leader = Some(leader);
|
|
self.group_members = members.into_iter().collect();
|
|
// Currently add/remove messages treat client as an implicit member
|
|
// of the group whereas this message explicitly includes them so to
|
|
// be consistent for now we will remove the client from the
|
|
// received hashset
|
|
if let Some(uid) = self.uid() {
|
|
self.group_members.remove(&uid);
|
|
}
|
|
frontend_events.push(Event::MapMarker(comp::MapMarkerUpdate::ClearGroup));
|
|
},
|
|
NoGroup => {
|
|
self.group_leader = None;
|
|
self.group_members = HashMap::new();
|
|
frontend_events.push(Event::MapMarker(comp::MapMarkerUpdate::ClearGroup));
|
|
},
|
|
}
|
|
},
|
|
ServerGeneral::Invite {
|
|
inviter,
|
|
timeout,
|
|
kind,
|
|
} => {
|
|
self.invite = Some((inviter, Instant::now(), timeout, kind));
|
|
},
|
|
ServerGeneral::InvitePending(uid) => {
|
|
if !self.pending_invites.insert(uid) {
|
|
warn!("Received message about pending invite that was already pending");
|
|
}
|
|
},
|
|
ServerGeneral::InviteComplete {
|
|
target,
|
|
answer,
|
|
kind,
|
|
} => {
|
|
if !self.pending_invites.remove(&target) {
|
|
warn!(
|
|
"Received completed invite message for invite that was not in the list of \
|
|
pending invites"
|
|
)
|
|
}
|
|
frontend_events.push(Event::InviteComplete {
|
|
target,
|
|
answer,
|
|
kind,
|
|
});
|
|
},
|
|
ServerGeneral::GroupInventoryUpdate(item, taker, uid) => {
|
|
frontend_events.push(Event::GroupInventoryUpdate(item, taker, uid));
|
|
},
|
|
// Cleanup for when the client goes back to the `presence = None`
|
|
ServerGeneral::ExitInGameSuccess => {
|
|
self.presence = None;
|
|
self.clean_state();
|
|
},
|
|
ServerGeneral::InventoryUpdate(inventory, events) => {
|
|
let mut update_inventory = false;
|
|
for event in events.iter() {
|
|
match event {
|
|
InventoryUpdateEvent::BlockCollectFailed { .. } => {},
|
|
InventoryUpdateEvent::EntityCollectFailed { .. } => {},
|
|
_ => update_inventory = true,
|
|
}
|
|
}
|
|
if update_inventory {
|
|
// Push the updated inventory component to the client
|
|
// FIXME: Figure out whether this error can happen under normal gameplay,
|
|
// if not find a better way to handle it, if so maybe consider kicking the
|
|
// client back to login?
|
|
let entity = self.entity();
|
|
if let Err(e) = self
|
|
.state
|
|
.ecs_mut()
|
|
.write_storage()
|
|
.insert(entity, inventory)
|
|
{
|
|
warn!(
|
|
?e,
|
|
"Received an inventory update event for client entity, but this \
|
|
entity was not found... this may be a bug."
|
|
);
|
|
}
|
|
}
|
|
|
|
self.update_available_recipes();
|
|
|
|
frontend_events.push(Event::InventoryUpdated(events));
|
|
},
|
|
ServerGeneral::SetViewDistance(vd) => {
|
|
self.view_distance = Some(vd);
|
|
frontend_events.push(Event::SetViewDistance(vd));
|
|
// If the server is correcting client vd selection we assume this is the max
|
|
// allowed view distance.
|
|
self.server_view_distance_limit = Some(vd);
|
|
},
|
|
ServerGeneral::Outcomes(outcomes) => {
|
|
frontend_events.extend(outcomes.into_iter().map(Event::Outcome))
|
|
},
|
|
ServerGeneral::Knockback(impulse) => {
|
|
self.state
|
|
.ecs()
|
|
.read_resource::<EventBus<LocalEvent>>()
|
|
.emit_now(LocalEvent::ApplyImpulse {
|
|
entity: self.entity(),
|
|
impulse,
|
|
});
|
|
},
|
|
ServerGeneral::UpdatePendingTrade(id, trade, pricing) => {
|
|
trace!("UpdatePendingTrade {:?} {:?}", id, trade);
|
|
self.pending_trade = Some((id, trade, pricing));
|
|
},
|
|
ServerGeneral::FinishedTrade(result) => {
|
|
if let Some((_, trade, _)) = self.pending_trade.take() {
|
|
self.update_available_recipes();
|
|
frontend_events.push(Event::TradeComplete { result, trade })
|
|
}
|
|
},
|
|
ServerGeneral::SiteEconomy(economy) => {
|
|
if let Some(rich) = self.sites_mut().get_mut(&economy.id) {
|
|
rich.economy = Some(economy);
|
|
}
|
|
},
|
|
ServerGeneral::MapMarker(event) => {
|
|
frontend_events.push(Event::MapMarker(event));
|
|
},
|
|
ServerGeneral::WeatherUpdate(weather) => {
|
|
self.weather.weather_update(weather);
|
|
},
|
|
ServerGeneral::SpectatePosition(pos) => {
|
|
frontend_events.push(Event::SpectatePosition(pos));
|
|
},
|
|
_ => unreachable!("Not a in_game message"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_server_terrain_msg(&mut self, msg: ServerGeneral) -> Result<(), Error> {
|
|
prof_span!("handle_server_terrain_mgs");
|
|
match msg {
|
|
ServerGeneral::TerrainChunkUpdate { key, chunk } => {
|
|
if let Some(chunk) = chunk.ok().and_then(|c| c.to_chunk()) {
|
|
self.state.insert_chunk(key, Arc::new(chunk));
|
|
}
|
|
self.pending_chunks.remove(&key);
|
|
},
|
|
ServerGeneral::LodZoneUpdate { key, zone } => {
|
|
self.lod_zones.insert(key, zone);
|
|
self.lod_last_requested = None;
|
|
},
|
|
ServerGeneral::TerrainBlockUpdates(blocks) => {
|
|
if let Some(mut blocks) = blocks.decompress() {
|
|
blocks.drain().for_each(|(pos, block)| {
|
|
self.state.set_block(pos, block);
|
|
});
|
|
}
|
|
},
|
|
_ => unreachable!("Not a terrain message"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_server_character_screen_msg(
|
|
&mut self,
|
|
events: &mut Vec<Event>,
|
|
msg: ServerGeneral,
|
|
) -> Result<(), Error> {
|
|
prof_span!("handle_server_character_screen_msg");
|
|
match msg {
|
|
ServerGeneral::CharacterListUpdate(character_list) => {
|
|
self.character_list.characters = character_list;
|
|
self.character_list.loading = false;
|
|
},
|
|
ServerGeneral::CharacterActionError(error) => {
|
|
warn!("CharacterActionError: {:?}.", error);
|
|
events.push(Event::CharacterError(error));
|
|
},
|
|
ServerGeneral::CharacterDataLoadResult(Ok(metadata)) => {
|
|
trace!("Handling join result by server");
|
|
events.push(Event::CharacterJoined(metadata));
|
|
},
|
|
ServerGeneral::CharacterDataLoadResult(Err(error)) => {
|
|
trace!("Handling join error by server");
|
|
self.presence = None;
|
|
self.clean_state();
|
|
events.push(Event::CharacterError(error));
|
|
},
|
|
ServerGeneral::CharacterCreated(character_id) => {
|
|
events.push(Event::CharacterCreated(character_id));
|
|
},
|
|
ServerGeneral::CharacterEdited(character_id) => {
|
|
events.push(Event::CharacterEdited(character_id));
|
|
},
|
|
ServerGeneral::CharacterSuccess => debug!("client is now in ingame state on server"),
|
|
ServerGeneral::SpectatorSuccess(spawn_point) => {
|
|
events.push(Event::StartSpectate(spawn_point));
|
|
debug!("client is now in ingame state on server");
|
|
},
|
|
_ => unreachable!("Not a character_screen msg"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_ping_msg(&mut self, msg: PingMsg) -> Result<(), Error> {
|
|
prof_span!("handle_ping_msg");
|
|
match msg {
|
|
PingMsg::Ping => {
|
|
self.send_msg_err(PingMsg::Pong)?;
|
|
},
|
|
PingMsg::Pong => {
|
|
self.last_server_pong = self.state.get_program_time();
|
|
self.last_ping_delta = self.state.get_program_time() - self.last_server_ping;
|
|
|
|
// Maintain the correct number of deltas for calculating the rolling average
|
|
// ping. The client sends a ping to the server every second so we should be
|
|
// receiving a pong reply roughly every second.
|
|
while self.ping_deltas.len() > PING_ROLLING_AVERAGE_SECS - 1 {
|
|
self.ping_deltas.pop_front();
|
|
}
|
|
self.ping_deltas.push_back(self.last_ping_delta);
|
|
},
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_messages(&mut self, frontend_events: &mut Vec<Event>) -> Result<u64, Error> {
|
|
let mut cnt = 0;
|
|
#[cfg(feature = "tracy")]
|
|
let (mut terrain_cnt, mut ingame_cnt) = (0, 0);
|
|
loop {
|
|
let cnt_start = cnt;
|
|
|
|
while let Some(msg) = self.general_stream.try_recv()? {
|
|
cnt += 1;
|
|
self.handle_server_msg(frontend_events, msg)?;
|
|
}
|
|
while let Some(msg) = self.ping_stream.try_recv()? {
|
|
cnt += 1;
|
|
self.handle_ping_msg(msg)?;
|
|
}
|
|
while let Some(msg) = self.character_screen_stream.try_recv()? {
|
|
cnt += 1;
|
|
self.handle_server_character_screen_msg(frontend_events, msg)?;
|
|
}
|
|
while let Some(msg) = self.in_game_stream.try_recv()? {
|
|
cnt += 1;
|
|
#[cfg(feature = "tracy")]
|
|
{
|
|
ingame_cnt += 1;
|
|
}
|
|
self.handle_server_in_game_msg(frontend_events, msg)?;
|
|
}
|
|
while let Some(msg) = self.terrain_stream.try_recv()? {
|
|
cnt += 1;
|
|
#[cfg(feature = "tracy")]
|
|
{
|
|
if let ServerGeneral::TerrainChunkUpdate { chunk, .. } = &msg {
|
|
terrain_cnt += chunk.as_ref().map(|x| x.approx_len()).unwrap_or(0);
|
|
}
|
|
}
|
|
self.handle_server_terrain_msg(msg)?;
|
|
}
|
|
|
|
if cnt_start == cnt {
|
|
#[cfg(feature = "tracy")]
|
|
{
|
|
plot!("terrain_recvs", terrain_cnt as f64);
|
|
plot!("ingame_recvs", ingame_cnt as f64);
|
|
}
|
|
return Ok(cnt);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle new server messages.
|
|
fn handle_new_messages(&mut self) -> Result<Vec<Event>, Error> {
|
|
prof_span!("handle_new_messages");
|
|
let mut frontend_events = Vec::new();
|
|
|
|
// Check that we have an valid connection.
|
|
// Use the last ping time as a 1s rate limiter, we only notify the user once per
|
|
// second
|
|
if self.state.get_program_time() - self.last_server_ping > 1. {
|
|
let duration_since_last_pong = self.state.get_program_time() - self.last_server_pong;
|
|
|
|
// Dispatch a notification to the HUD warning they will be kicked in {n} seconds
|
|
const KICK_WARNING_AFTER_REL_TO_TIMEOUT_FRACTION: f64 = 0.75;
|
|
if duration_since_last_pong
|
|
>= (self.client_timeout.as_secs() as f64
|
|
* KICK_WARNING_AFTER_REL_TO_TIMEOUT_FRACTION)
|
|
&& self.state.get_program_time() - duration_since_last_pong > 0.
|
|
{
|
|
frontend_events.push(Event::DisconnectionNotification(
|
|
(self.state.get_program_time() - duration_since_last_pong).round() as u64,
|
|
));
|
|
}
|
|
}
|
|
|
|
let msg_count = self.handle_messages(&mut frontend_events)?;
|
|
|
|
if msg_count == 0
|
|
&& self.state.get_program_time() - self.last_server_pong
|
|
> self.client_timeout.as_secs() as f64
|
|
{
|
|
dbg!(self.state.get_program_time());
|
|
dbg!(self.last_server_pong);
|
|
return Err(Error::ServerTimeout);
|
|
}
|
|
|
|
// ignore network events
|
|
while let Some(res) = self
|
|
.participant
|
|
.as_mut()
|
|
.and_then(|p| p.try_fetch_event().transpose())
|
|
{
|
|
let event = res?;
|
|
trace!(?event, "received network event");
|
|
}
|
|
|
|
Ok(frontend_events)
|
|
}
|
|
|
|
pub fn entity(&self) -> EcsEntity {
|
|
self.state
|
|
.ecs()
|
|
.read_resource::<PlayerEntity>()
|
|
.0
|
|
.expect("Client::entity should always have PlayerEntity be Some")
|
|
}
|
|
|
|
pub fn uid(&self) -> Option<Uid> { self.state.read_component_copied(self.entity()) }
|
|
|
|
pub fn presence(&self) -> Option<PresenceKind> { self.presence }
|
|
|
|
pub fn registered(&self) -> bool { self.registered }
|
|
|
|
pub fn get_tick(&self) -> u64 { self.tick }
|
|
|
|
pub fn get_ping_ms(&self) -> f64 { self.last_ping_delta * 1000.0 }
|
|
|
|
pub fn get_ping_ms_rolling_avg(&self) -> f64 {
|
|
let mut total_weight = 0.;
|
|
let pings = self.ping_deltas.len() as f64;
|
|
(self
|
|
.ping_deltas
|
|
.iter()
|
|
.enumerate()
|
|
.fold(0., |acc, (i, ping)| {
|
|
let weight = i as f64 + 1. / pings;
|
|
total_weight += weight;
|
|
acc + (weight * ping)
|
|
})
|
|
/ total_weight)
|
|
* 1000.0
|
|
}
|
|
|
|
/// Get a reference to the client's runtime thread pool. This pool should be
|
|
/// used for any computationally expensive operations that run outside
|
|
/// of the main thread (i.e., threads that block on I/O operations are
|
|
/// exempt).
|
|
pub fn runtime(&self) -> &Arc<Runtime> { &self.runtime }
|
|
|
|
/// Get a reference to the client's game state.
|
|
pub fn state(&self) -> &State { &self.state }
|
|
|
|
/// Get a mutable reference to the client's game state.
|
|
pub fn state_mut(&mut self) -> &mut State { &mut self.state }
|
|
|
|
/// Returns an iterator over the aliases of all the online players on the
|
|
/// server
|
|
pub fn players(&self) -> impl Iterator<Item = &str> {
|
|
self.player_list()
|
|
.values()
|
|
.filter_map(|player_info| player_info.is_online.then_some(&*player_info.player_alias))
|
|
}
|
|
|
|
/// Return true if this client is a moderator on the server
|
|
pub fn is_moderator(&self) -> bool {
|
|
let client_uid = self
|
|
.state
|
|
.read_component_copied::<Uid>(self.entity())
|
|
.expect("Client doesn't have a Uid!!!");
|
|
|
|
self.player_list
|
|
.get(&client_uid)
|
|
.map_or(false, |info| info.is_moderator)
|
|
}
|
|
|
|
/// Clean client ECS state
|
|
fn clean_state(&mut self) {
|
|
// Clear pending trade
|
|
self.pending_trade = None;
|
|
|
|
let client_uid = self.uid().expect("Client doesn't have a Uid!!!");
|
|
|
|
// Clear ecs of all entities
|
|
self.state.ecs_mut().delete_all();
|
|
self.state.ecs_mut().maintain();
|
|
self.state.ecs_mut().insert(IdMaps::default());
|
|
|
|
// Recreate client entity with Uid
|
|
let entity_builder = self.state.ecs_mut().create_entity();
|
|
entity_builder
|
|
.world
|
|
.write_resource::<IdMaps>()
|
|
.add_entity(client_uid, entity_builder.entity);
|
|
|
|
let entity = entity_builder.with(client_uid).build();
|
|
self.state.ecs().write_resource::<PlayerEntity>().0 = Some(entity);
|
|
}
|
|
|
|
/// Change player alias to "You" if client belongs to matching player
|
|
pub fn personalize_alias(&self, uid: Uid, alias: String) -> String {
|
|
let client_uid = self.uid().expect("Client doesn't have a Uid!!!");
|
|
if client_uid == uid {
|
|
"You".to_string() // TODO: Localize
|
|
} else {
|
|
alias
|
|
}
|
|
}
|
|
|
|
/// Get important information from client that is necessary for message
|
|
/// localisation
|
|
pub fn lookup_msg_context(&self, msg: &comp::ChatMsg) -> ChatTypeContext {
|
|
let mut result = ChatTypeContext {
|
|
you: self.uid().expect("Client doesn't have a Uid!!!"),
|
|
player_alias: HashMap::new(),
|
|
entity_name: HashMap::new(),
|
|
};
|
|
let name_of_uid = |uid| {
|
|
let ecs = self.state.ecs();
|
|
(
|
|
&ecs.read_storage::<comp::Stats>(),
|
|
&ecs.read_storage::<Uid>(),
|
|
)
|
|
.join()
|
|
.find(|(_, u)| u == &uid)
|
|
.map(|(c, _)| c.name.clone())
|
|
};
|
|
let mut alias_of_uid = |uid| match self.player_list.get(uid) {
|
|
Some(player_info) => {
|
|
result.player_alias.insert(*uid, player_info.clone());
|
|
},
|
|
None => {
|
|
result
|
|
.entity_name
|
|
.insert(*uid, name_of_uid(uid).unwrap_or_else(|| "<?>".to_string()));
|
|
},
|
|
};
|
|
match &msg.chat_type {
|
|
comp::ChatType::Online(uid) | comp::ChatType::Offline(uid) => {
|
|
alias_of_uid(uid);
|
|
},
|
|
comp::ChatType::CommandError => (),
|
|
comp::ChatType::CommandInfo => (),
|
|
comp::ChatType::FactionMeta(_) => (),
|
|
comp::ChatType::GroupMeta(_) => (),
|
|
comp::ChatType::Kill(kill_source, victim) => {
|
|
alias_of_uid(victim);
|
|
match kill_source {
|
|
KillSource::Player(attacker_uid, _) => {
|
|
alias_of_uid(attacker_uid);
|
|
},
|
|
KillSource::NonPlayer(_, _) => (),
|
|
KillSource::Environment(_) => (),
|
|
KillSource::FallDamage => (),
|
|
KillSource::Suicide => (),
|
|
KillSource::NonExistent(_) => (),
|
|
KillSource::Other => (),
|
|
};
|
|
},
|
|
comp::ChatType::Tell(from, to) | comp::ChatType::NpcTell(from, to) => {
|
|
alias_of_uid(from);
|
|
alias_of_uid(to);
|
|
},
|
|
comp::ChatType::Say(uid)
|
|
| comp::ChatType::Region(uid)
|
|
| comp::ChatType::World(uid)
|
|
| comp::ChatType::NpcSay(uid) => {
|
|
alias_of_uid(uid);
|
|
},
|
|
comp::ChatType::Group(uid, _) | comp::ChatType::Faction(uid, _) => {
|
|
alias_of_uid(uid);
|
|
},
|
|
comp::ChatType::Npc(uid) => alias_of_uid(uid),
|
|
comp::ChatType::Meta => (),
|
|
};
|
|
result
|
|
}
|
|
|
|
/// Execute a single client tick:
|
|
/// - handles messages from the server
|
|
/// - sends physics update
|
|
/// - requests chunks
|
|
///
|
|
/// The game state is purposefully not simulated to reduce the overhead of
|
|
/// running the client. This method is for use in testing a server with
|
|
/// many clients connected.
|
|
#[cfg(feature = "tick_network")]
|
|
#[allow(clippy::needless_collect)] // False positive
|
|
pub fn tick_network(&mut self, dt: Duration) -> Result<(), Error> {
|
|
span!(_guard, "tick_network", "Client::tick_network");
|
|
// Advance state time manually since we aren't calling `State::tick`
|
|
self.state
|
|
.ecs()
|
|
.write_resource::<common::resources::ProgramTime>()
|
|
.0 += dt.as_secs_f64();
|
|
|
|
let time_scale = *self
|
|
.state
|
|
.ecs()
|
|
.read_resource::<common::resources::TimeScale>();
|
|
self.state
|
|
.ecs()
|
|
.write_resource::<common::resources::Time>()
|
|
.0 += dt.as_secs_f64() * time_scale.0;
|
|
|
|
// Handle new messages from the server.
|
|
self.handle_new_messages()?;
|
|
|
|
// 5) Terrain
|
|
self.tick_terrain()?;
|
|
let empty = Arc::new(TerrainChunk::new(
|
|
0,
|
|
Block::empty(),
|
|
Block::empty(),
|
|
common::terrain::TerrainChunkMeta::void(),
|
|
));
|
|
let mut terrain = self.state.terrain_mut();
|
|
// Replace chunks with empty chunks to save memory
|
|
let to_clear = terrain
|
|
.iter()
|
|
.filter_map(|(key, chunk)| (chunk.sub_chunks_len() != 0).then(|| key))
|
|
.collect::<Vec<_>>();
|
|
to_clear.into_iter().for_each(|key| {
|
|
terrain.insert(key, Arc::clone(&empty));
|
|
});
|
|
drop(terrain);
|
|
|
|
// Send a ping to the server once every second
|
|
if self.state.get_program_time() - self.last_server_ping > 1. {
|
|
self.send_msg_err(PingMsg::Ping)?;
|
|
self.last_server_ping = self.state.get_program_time();
|
|
}
|
|
|
|
// 6) Update the server about the player's physics attributes.
|
|
if self.presence.is_some() {
|
|
if let (Some(pos), Some(vel), Some(ori)) = (
|
|
self.state.read_storage().get(self.entity()).cloned(),
|
|
self.state.read_storage().get(self.entity()).cloned(),
|
|
self.state.read_storage().get(self.entity()).cloned(),
|
|
) {
|
|
self.in_game_stream.send(ClientGeneral::PlayerPhysics {
|
|
pos,
|
|
vel,
|
|
ori,
|
|
force_counter: self.force_update_counter,
|
|
})?;
|
|
}
|
|
}
|
|
|
|
// 7) Finish the tick, pass control back to the frontend.
|
|
self.tick += 1;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for Client {
|
|
fn drop(&mut self) {
|
|
trace!("Dropping client");
|
|
if self.registered {
|
|
if let Err(e) = self.send_msg_err(ClientGeneral::Terminate) {
|
|
warn!(
|
|
?e,
|
|
"Error during drop of client, couldn't send disconnect package, is the \
|
|
connection already closed?",
|
|
);
|
|
}
|
|
} else {
|
|
trace!("no disconnect msg necessary as client wasn't registered")
|
|
}
|
|
|
|
tokio::task::block_in_place(|| {
|
|
if let Err(e) = self
|
|
.runtime
|
|
.block_on(self.participant.take().unwrap().disconnect())
|
|
{
|
|
warn!(?e, "error when disconnecting, couldn't send all data");
|
|
}
|
|
});
|
|
//explicitly drop the network here while the runtime is still existing
|
|
drop(self.network.take());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use client_i18n::LocalizationHandle;
|
|
|
|
#[test]
|
|
/// THIS TEST VERIFIES THE CONSTANT API.
|
|
/// CHANGING IT WILL BREAK 3rd PARTY APPLICATIONS (please extend) which
|
|
/// needs to be informed (or fixed)
|
|
/// - torvus: https://gitlab.com/veloren/torvus
|
|
/// CONTACT @Core Developer BEFORE MERGING CHANGES TO THIS TEST
|
|
fn constant_api_test() {
|
|
use common::clock::Clock;
|
|
use voxygen_i18n_helpers::localize_chat_message;
|
|
|
|
const SPT: f64 = 1.0 / 60.0;
|
|
|
|
let runtime = Arc::new(Runtime::new().unwrap());
|
|
let runtime2 = Arc::clone(&runtime);
|
|
let username = "Foo";
|
|
let password = "Bar";
|
|
let auth_server = "auth.veloren.net";
|
|
let veloren_client: Result<Client, Error> = runtime.block_on(Client::new(
|
|
ConnectionArgs::Tcp {
|
|
hostname: "127.0.0.1:9000".to_owned(),
|
|
prefer_ipv6: false,
|
|
},
|
|
runtime2,
|
|
&mut None,
|
|
username,
|
|
password,
|
|
None,
|
|
|suggestion: &str| suggestion == auth_server,
|
|
&|_| {},
|
|
|_| {},
|
|
));
|
|
let localisation = LocalizationHandle::load_expect("en");
|
|
|
|
let _ = veloren_client.map(|mut client| {
|
|
//clock
|
|
let mut clock = Clock::new(Duration::from_secs_f64(SPT));
|
|
|
|
//tick
|
|
let events_result: Result<Vec<Event>, Error> =
|
|
client.tick(ControllerInputs::default(), clock.dt());
|
|
|
|
//chat functionality
|
|
client.send_chat("foobar".to_string());
|
|
|
|
let _ = events_result.map(|mut events| {
|
|
// event handling
|
|
if let Some(event) = events.pop() {
|
|
match event {
|
|
Event::Chat(msg) => {
|
|
let msg: comp::ChatMsg = msg;
|
|
let _s: String = localize_chat_message(
|
|
msg,
|
|
|msg| client.lookup_msg_context(msg),
|
|
&localisation.read(),
|
|
true,
|
|
)
|
|
.1;
|
|
},
|
|
Event::Disconnect => {},
|
|
Event::DisconnectionNotification(_) => {
|
|
debug!("Will be disconnected soon! :/")
|
|
},
|
|
Event::Notification(notification) => {
|
|
let notification: Notification = notification;
|
|
debug!("Notification: {:?}", notification);
|
|
},
|
|
_ => {},
|
|
}
|
|
};
|
|
});
|
|
|
|
client.cleanup();
|
|
clock.tick();
|
|
});
|
|
}
|
|
}
|