mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
6c6b9181a5
* use version of shred that has an added SendDispatcher so we can construct the dispatcher and send it between threads (only State to remain sendable) * move closure for adding systems from State::tick to the creation functions * this does mean some voxygen systems always run instead of just in the session state, but that should not cause issues and we can always configure them to do nothing if needed
3063 lines
115 KiB
Rust
3063 lines
115 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::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,
|
|
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,
|
|
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>,
|
|
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,
|
|
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,
|
|
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,
|
|
} = 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,
|
|
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,
|
|
|
|
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,
|
|
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 })?;
|
|
|
|
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
|
|
},
|
|
//Only in game, 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 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 }
|
|
|
|
/// 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 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));
|
|
|
|
// Manage LoD zones
|
|
let lod_zone = pos.0.xy().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,
|
|
|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();
|
|
});
|
|
}
|
|
}
|