diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..b60de5b5f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +**/target diff --git a/.gitignore b/.gitignore index ad3297ca3c..eb77a24e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ maps screenshots todo.txt +# Game data +*.sqlite +*.sqlite-journal + # direnv /.envrc *.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aec553aeb..1978b3b1ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Villagers tools and clothing - Cultists clothing - You can start the game by pressing "enter" from the character selection menu +- Added server-side character saving ### Changed diff --git a/Cargo.lock b/Cargo.lock index 98cf25c4af..2f87657016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1100,6 +1100,38 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "307dde1a517939465bc4042b47377284a56cee6160f8066f1f5035eb7b25a3fc" +[[package]] +name = "diesel" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d7ca63eb2efea87a7f56a283acc49e2ce4b2bd54adf7465dc1d81fef13d8fc" +dependencies = [ + "byteorder 1.3.4", + "diesel_derives", + "libsqlite3-sys", +] + +[[package]] +name = "diesel_derives" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f5098f628d02a7a0f68ddba586fb61e80edec3bdc1be3b921f4ceec60858d3" +dependencies = [ + "proc-macro2 1.0.9", + "quote 1.0.3", + "syn 1.0.16", +] + +[[package]] +name = "diesel_migrations" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3cde8413353dc7f5d72fa8ce0b99a560a359d2c5ef1e5817ca731cd9008f4c" +dependencies = [ + "migrations_internals", + "migrations_macros", +] + [[package]] name = "directories" version = "2.0.2" @@ -1166,6 +1198,12 @@ dependencies = [ "nom 4.2.3", ] +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "downcast-rs" version = "1.1.1" @@ -2401,6 +2439,17 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "libsqlite3-sys" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3711dfd91a1081d2458ad2d06ea30a8755256e74038be2ad927d94e1c955ca8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libssh2-sys" version = "0.2.16" @@ -2550,6 +2599,27 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "migrations_internals" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4fc84e4af020b837029e017966f86a1c2d5e83e64b589963d5047525995860" +dependencies = [ + "diesel", +] + +[[package]] +name = "migrations_macros" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9753f12909fd8d923f75ae5c3258cae1ed3c8ec052e1b38c93c21a6d157f789c" +dependencies = [ + "migrations_internals", + "proc-macro2 1.0.9", + "quote 1.0.3", + "syn 1.0.16", +] + [[package]] name = "mime" version = "0.2.6" @@ -4952,8 +5022,12 @@ dependencies = [ "authc", "chrono", "crossbeam", + "diesel", + "diesel_migrations", + "dotenv", "hashbrown", "lazy_static", + "libsqlite3-sys", "log 0.4.8", "portpicker", "prometheus", diff --git a/assets/voxygen/i18n/en.ron b/assets/voxygen/i18n/en.ron index 866108d34b..9bdeaa9367 100644 --- a/assets/voxygen/i18n/en.ron +++ b/assets/voxygen/i18n/en.ron @@ -337,11 +337,14 @@ Enjoy your stay in the World of Veloren."#, /// Start chracter selection section + "char_selection.loading_characters": "Loading characters...", "char_selection.delete_permanently": "Permanently delete this Character?", + "char_selection.deleting_character": "Deleting Character...", "char_selection.change_server": "Change Server", "char_selection.enter_world": "Enter World", "char_selection.logout": "Logout", "char_selection.create_new_charater": "Create New Character", + "char_selection.creating_character": "Creating Character...", "char_selection.character_creation": "Character Creation", "char_selection.human_default": "Human Default", diff --git a/client/src/lib.rs b/client/src/lib.rs index 5f9b4b594a..df4b3bd8bb 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -14,6 +14,7 @@ pub use specs::{ use byteorder::{ByteOrder, LittleEndian}; use common::{ + character::CharacterItem, comp::{ self, ControlAction, ControlEvent, Controller, ControllerInputs, InventoryManip, InventoryUpdateEvent, @@ -65,6 +66,7 @@ pub struct Client { pub server_info: ServerInfo, pub world_map: (Arc, Vec2), pub player_list: HashMap, + pub character_list: CharacterList, postbox: PostBox, @@ -83,6 +85,15 @@ pub struct Client { pending_chunks: HashMap, Instant>, } +/// Holds data related to the current players characters, as well as some +/// additional state to handle UI. +#[derive(Default)] +pub struct CharacterList { + pub characters: Vec, + pub loading: bool, + pub error: Option, +} + impl Client { /// Create a new `Client`. pub fn new>(addr: A, view_distance: Option) -> Result { @@ -158,6 +169,7 @@ impl Client { server_info, world_map, player_list: HashMap::new(), + character_list: CharacterList::default(), postbox, @@ -224,9 +236,30 @@ impl Client { pub fn request_character(&mut self, name: String, body: comp::Body, main: Option) { self.postbox .send_message(ClientMsg::Character { name, body, main }); + self.client_state = ClientState::Pending; } + /// Load the current players character list + pub fn load_characters(&mut self) { + self.character_list.loading = true; + self.postbox.send_message(ClientMsg::RequestCharacterList); + } + + /// New character creation + pub fn create_character(&mut self, alias: String, tool: Option, body: comp::Body) { + self.character_list.loading = true; + self.postbox + .send_message(ClientMsg::CreateCharacter { alias, tool, body }); + } + + /// Character deletion + pub fn delete_character(&mut self, character_id: i32) { + self.character_list.loading = true; + self.postbox + .send_message(ClientMsg::DeleteCharacter(character_id)); + } + /// Send disconnect message to the server pub fn request_logout(&mut self) { self.postbox.send_message(ClientMsg::Disconnect); } @@ -819,6 +852,14 @@ impl Client { frontend_events.push(Event::Disconnect); self.postbox.send_message(ClientMsg::Terminate); }, + ServerMsg::CharacterListUpdate(character_list) => { + self.character_list.characters = character_list; + self.character_list.loading = false; + }, + ServerMsg::CharacterActionError(error) => { + warn!("CharacterActionError: {:?}.", error); + self.character_list.error = Some(error); + }, } } } else if let Some(err) = self.postbox.error() { diff --git a/common/src/character.rs b/common/src/character.rs new file mode 100644 index 0000000000..ed20c15092 --- /dev/null +++ b/common/src/character.rs @@ -0,0 +1,20 @@ +use crate::comp; +use serde_derive::{Deserialize, Serialize}; + +/// The limit on how many characters that a player can have +pub const MAX_CHARACTERS_PER_PLAYER: usize = 8; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Character { + pub id: Option, + pub alias: String, + pub tool: Option, // TODO: Remove once we start persisting inventories +} + +/// Represents the character data sent by the server after loading from the +/// database. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CharacterItem { + pub character: Character, + pub body: comp::Body, +} diff --git a/common/src/comp/body/humanoid.rs b/common/src/comp/body/humanoid.rs index 5164933e7d..91b6c483ef 100644 --- a/common/src/comp/body/humanoid.rs +++ b/common/src/comp/body/humanoid.rs @@ -7,7 +7,7 @@ pub struct Body { pub body_type: BodyType, pub hair_style: u8, pub beard: u8, - pub eyebrows: Eyebrows, + pub eyebrows: u8, pub accessory: u8, pub hair_color: u8, pub skin: u8, @@ -29,7 +29,7 @@ impl Body { body_type, hair_style: rng.gen_range(0, race.num_hair_styles(body_type)), beard: rng.gen_range(0, race.num_beards(body_type)), - eyebrows: *(&ALL_EYEBROWS).choose(rng).unwrap(), + eyebrows: rng.gen_range(0, race.num_eyebrows(body_type)), accessory: rng.gen_range(0, race.num_accessories(body_type)), hair_color: rng.gen_range(0, race.num_hair_colors()) as u8, skin: rng.gen_range(0, race.num_skin_colors()) as u8, @@ -445,6 +445,8 @@ impl Race { } } + pub fn num_eyebrows(self, _body_type: BodyType) -> u8 { 1 } + pub fn num_beards(self, body_type: BodyType) -> u8 { match (self, body_type) { (Race::Danari, BodyType::Female) => 1, diff --git a/common/src/comp/player.rs b/common/src/comp/player.rs index b7a6aa1a3d..d75a4c7069 100644 --- a/common/src/comp/player.rs +++ b/common/src/comp/player.rs @@ -7,14 +7,21 @@ const MAX_ALIAS_LEN: usize = 32; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Player { pub alias: String, + pub character_id: Option, pub view_distance: Option, uuid: Uuid, } impl Player { - pub fn new(alias: String, view_distance: Option, uuid: Uuid) -> Self { + pub fn new( + alias: String, + character_id: Option, + view_distance: Option, + uuid: Uuid, + ) -> Self { Self { alias, + character_id, view_distance, uuid, } diff --git a/common/src/event.rs b/common/src/event.rs index 924d92ef84..8c9949105e 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -90,7 +90,7 @@ pub enum ServerEvent { Mount(EcsEntity, EcsEntity), Unmount(EcsEntity), Possess(Uid, Uid), - CreateCharacter { + SelectCharacter { entity: EcsEntity, name: String, body: comp::Body, diff --git a/common/src/lib.rs b/common/src/lib.rs index 899578a760..1ea3df4c51 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -14,6 +14,7 @@ pub mod assets; pub mod astar; +pub mod character; pub mod clock; pub mod comp; pub mod effect; diff --git a/common/src/msg/client.rs b/common/src/msg/client.rs index 970208cc23..225d324188 100644 --- a/common/src/msg/client.rs +++ b/common/src/msg/client.rs @@ -7,6 +7,13 @@ pub enum ClientMsg { view_distance: Option, token_or_username: String, }, + RequestCharacterList, + CreateCharacter { + alias: String, + tool: Option, + body: comp::Body, + }, + DeleteCharacter(i32), Character { name: String, body: comp::Body, diff --git a/common/src/msg/server.rs b/common/src/msg/server.rs index 5e6fecac33..d997206753 100644 --- a/common/src/msg/server.rs +++ b/common/src/msg/server.rs @@ -1,5 +1,6 @@ use super::{ClientState, EcsCompPacket}; use crate::{ + character::CharacterItem, comp, state, sync, terrain::{Block, TerrainChunk}, ChatType, @@ -33,6 +34,10 @@ pub enum ServerMsg { time_of_day: state::TimeOfDay, world_map: (Vec2, Vec), }, + /// A list of characters belonging to the a authenticated player was sent + CharacterListUpdate(Vec), + /// An error occured while creating or deleting a character + CharacterActionError(String), PlayerListUpdate(PlayerListUpdate), StateAnswer(Result), /// Trigger cleanup for when the client goes back to the `Registered` state diff --git a/server-cli/docker-compose.yml b/server-cli/docker-compose.yml index 6999700d37..1e9346c3e3 100644 --- a/server-cli/docker-compose.yml +++ b/server-cli/docker-compose.yml @@ -8,6 +8,8 @@ services: - "14004:14004" - "14005:14005" restart: on-failure:0 + volumes: + - "./db:/opt/db" watchtower: image: containrrr/watchtower volumes: diff --git a/server/Cargo.toml b/server/Cargo.toml index e93df3f701..128ca49644 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -32,3 +32,7 @@ prometheus-static-metric = "0.2" rouille = "3.0.0" portpicker = { git = "https://github.com/wusyong/portpicker-rs", branch = "fix_ipv6" } authc = { git = "https://gitlab.com/veloren/auth.git", rev = "65571ade0d954a0e0bd995fdb314854ff146ab97" } +libsqlite3-sys = { version = "0.9.1", features = ["bundled"] } +diesel = { version = "1.4.3", features = ["sqlite"] } +diesel_migrations = "1.4.0" +dotenv = "0.15.0" \ No newline at end of file diff --git a/server/src/events/mod.rs b/server/src/events/mod.rs index 1f9ab9ff0e..c56b695682 100644 --- a/server/src/events/mod.rs +++ b/server/src/events/mod.rs @@ -69,7 +69,7 @@ impl Server { ServerEvent::Possess(possessor_uid, possesse_uid) => { handle_possess(&self, possessor_uid, possesse_uid) }, - ServerEvent::CreateCharacter { + ServerEvent::SelectCharacter { entity, name, body, diff --git a/server/src/lib.rs b/server/src/lib.rs index ecb35c6594..97126cb648 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod events; pub mod input; pub mod metrics; +pub mod persistence; pub mod settings; pub mod state_ext; pub mod sys; @@ -54,6 +55,9 @@ use world::{ World, }; +#[macro_use] extern crate diesel; +#[macro_use] extern crate diesel_migrations; + const CLIENT_TIMEOUT: f64 = 20.0; // Seconds #[derive(Copy, Clone)] @@ -215,7 +219,16 @@ impl Server { .expect("Failed to initialize server metrics submodule."), server_settings: settings.clone(), }; + + // Run pending DB migrations (if any) + debug!("Running DB migrations..."); + + if let Some(error) = persistence::run_migrations().err() { + log::info!("Migration error: {}", format!("{:#?}", error)); + } + debug!("created veloren server with: {:?}", &settings); + log::info!( "Server version: {}[{}]", *common::util::GIT_HASH, diff --git a/server/src/migrations/2020-04-11-202519_character/down.sql b/server/src/migrations/2020-04-11-202519_character/down.sql new file mode 100644 index 0000000000..208d112408 --- /dev/null +++ b/server/src/migrations/2020-04-11-202519_character/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "character"; \ No newline at end of file diff --git a/server/src/migrations/2020-04-11-202519_character/up.sql b/server/src/migrations/2020-04-11-202519_character/up.sql new file mode 100644 index 0000000000..c1d19f2053 --- /dev/null +++ b/server/src/migrations/2020-04-11-202519_character/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "character" ( + id INTEGER NOT NULL PRIMARY KEY, + player_uuid TEXT NOT NULL, + alias TEXT NOT NULL, + tool TEXT +); \ No newline at end of file diff --git a/server/src/migrations/2020-04-19-025352_body/down.sql b/server/src/migrations/2020-04-19-025352_body/down.sql new file mode 100644 index 0000000000..7d0323f2c3 --- /dev/null +++ b/server/src/migrations/2020-04-19-025352_body/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "body"; \ No newline at end of file diff --git a/server/src/migrations/2020-04-19-025352_body/up.sql b/server/src/migrations/2020-04-19-025352_body/up.sql new file mode 100644 index 0000000000..98ac69a59f --- /dev/null +++ b/server/src/migrations/2020-04-19-025352_body/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS "body" ( + character_id INT NOT NULL PRIMARY KEY, + race SMALLINT NOT NULL, + body_type SMALLINT NOT NULL, + hair_style SMALLINT NOT NULL, + beard SMALLINT NOT NULL, + eyebrows SMALLINT NOT NULL, + accessory SMALLINT NOT NULL, + hair_color SMALLINT NOT NULL, + skin SMALLINT NOT NULL, + eye_color SMALLINT NOT NULL, + FOREIGN KEY(character_id) REFERENCES "character"(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/server/src/persistence/.env b/server/src/persistence/.env new file mode 100644 index 0000000000..a165a1410b --- /dev/null +++ b/server/src/persistence/.env @@ -0,0 +1 @@ +DATABASE_URL=../../../saves/db.sqlite diff --git a/server/src/persistence/character.rs b/server/src/persistence/character.rs new file mode 100644 index 0000000000..2f45619534 --- /dev/null +++ b/server/src/persistence/character.rs @@ -0,0 +1,125 @@ +extern crate diesel; + +use super::{ + establish_connection, + models::{Body, Character, NewCharacter}, + schema, Error, +}; +use crate::comp; +use common::character::{Character as CharacterData, CharacterItem, MAX_CHARACTERS_PER_PLAYER}; +use diesel::prelude::*; + +type CharacterListResult = Result, Error>; + +// Loading of characters happens immediately after login, and the data is only +// for the purpose of rendering the character and their level in the character +// list. +pub fn load_characters(uuid: &str) -> CharacterListResult { + use schema::{body, character::dsl::*}; + + let data: Vec<(Character, Body)> = character + .filter(player_uuid.eq(uuid)) + .order(id.desc()) + .inner_join(body::table) + .load::<(Character, Body)>(&establish_connection())?; + + Ok(data + .iter() + .map(|(character_data, body_data)| CharacterItem { + character: CharacterData::from(character_data), + body: comp::Body::from(body_data), + }) + .collect()) +} + +/// Create a new character with provided comp::Character and comp::Body data. +/// Note that sqlite does not suppport returning the inserted data after a +/// successful insert. To workaround, we wrap this in a transaction which +/// inserts, queries for the newly created chaacter id, then uses the character +/// id for insertion of the `body` table entry +pub fn create_character( + uuid: &str, + alias: String, + tool: Option, + body: &comp::Body, +) -> CharacterListResult { + check_character_limit(uuid)?; + + let new_character = NewCharacter { + player_uuid: uuid, + alias: &alias, + tool: tool.as_deref(), + }; + + let connection = establish_connection(); + + connection.transaction::<_, diesel::result::Error, _>(|| { + use schema::{body, character, character::dsl::*}; + + match body { + comp::Body::Humanoid(body_data) => { + diesel::insert_into(character::table) + .values(&new_character) + .execute(&connection)?; + + let inserted_character = character + .filter(player_uuid.eq(uuid)) + .order(id.desc()) + .first::(&connection)?; + + let new_body = Body { + character_id: inserted_character.id as i32, + race: body_data.race as i16, + body_type: body_data.body_type as i16, + hair_style: body_data.hair_style as i16, + beard: body_data.beard as i16, + eyebrows: body_data.eyebrows as i16, + accessory: body_data.accessory as i16, + hair_color: body_data.hair_color as i16, + skin: body_data.skin as i16, + eye_color: body_data.eye_color as i16, + }; + + diesel::insert_into(body::table) + .values(&new_body) + .execute(&connection)?; + }, + _ => log::warn!("Creating non-humanoid characters is not supported."), + }; + + Ok(()) + })?; + + load_characters(uuid) +} + +pub fn delete_character(uuid: &str, character_id: i32) -> CharacterListResult { + use schema::character::dsl::*; + + diesel::delete(character.filter(id.eq(character_id))).execute(&establish_connection())?; + + load_characters(uuid) +} + +fn check_character_limit(uuid: &str) -> Result<(), Error> { + use diesel::dsl::count_star; + use schema::character::dsl::*; + + let connection = establish_connection(); + + let character_count = character + .select(count_star()) + .filter(player_uuid.eq(uuid)) + .load::(&connection)?; + + match character_count.first() { + Some(count) => { + if count < &(MAX_CHARACTERS_PER_PLAYER as i64) { + Ok(()) + } else { + Err(Error::CharacterLimitReached) + } + }, + _ => Ok(()), + } +} diff --git a/server/src/persistence/diesel.toml b/server/src/persistence/diesel.toml new file mode 100644 index 0000000000..56bd2de8f1 --- /dev/null +++ b/server/src/persistence/diesel.toml @@ -0,0 +1,5 @@ +# For documentation on how to configure this file, +# see diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "schema.rs" diff --git a/server/src/persistence/mod.rs b/server/src/persistence/mod.rs new file mode 100644 index 0000000000..6aa08213f0 --- /dev/null +++ b/server/src/persistence/mod.rs @@ -0,0 +1,62 @@ +pub mod character; +mod models; + +mod schema; + +extern crate diesel; + +use diesel::prelude::*; +use diesel_migrations::embed_migrations; +use std::{env, fmt, fs, path::Path}; + +#[derive(Debug)] +pub enum Error { + // The player has alredy reached the max character limit + CharacterLimitReached, + // An error occured when performing a database action + DatabaseError(diesel::result::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", match self { + Self::DatabaseError(diesel_error) => diesel_error.to_string(), + Self::CharacterLimitReached => String::from("Character limit exceeded"), + }) + } +} + +impl From for Error { + fn from(error: diesel::result::Error) -> Error { Error::DatabaseError(error) } +} + +// See: https://docs.rs/diesel_migrations/1.4.0/diesel_migrations/macro.embed_migrations.html +// This macro is called at build-time, and produces the necessary migration info +// for the `embedded_migrations` call below. +embed_migrations!(); + +pub fn run_migrations() -> Result<(), diesel_migrations::RunMigrationsError> { + let _ = fs::create_dir(format!("{}/saves/", binary_absolute_path())); + embedded_migrations::run_with_output(&establish_connection(), &mut std::io::stdout()) +} + +fn establish_connection() -> SqliteConnection { + let database_url = format!("{}/saves/db.sqlite", binary_absolute_path()); + SqliteConnection::establish(&database_url) + .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) +} + +// Get the absolute path of the binary so that the database is always stored +// beside it, no matter where the binary is run from +fn binary_absolute_path() -> String { + let binary_path; + match env::current_exe() { + Ok(exe_path) => binary_path = exe_path, + Err(e) => panic!("Failed to get current exe path: {}", e), + }; + + match Path::new(&binary_path.display().to_string()).parent() { + Some(path) => return path.display().to_string(), + None => panic!("Failed to get current exe parent path"), + }; +} diff --git a/server/src/persistence/models.rs b/server/src/persistence/models.rs new file mode 100644 index 0000000000..0ddc108b45 --- /dev/null +++ b/server/src/persistence/models.rs @@ -0,0 +1,65 @@ +use super::schema::{body, character}; +use crate::comp; +use common::character::Character as CharacterData; + +/// `Character` represents a playable character belonging to a player +#[derive(Identifiable, Queryable, Debug)] +#[table_name = "character"] +pub struct Character { + pub id: i32, + pub player_uuid: String, + pub alias: String, + pub tool: Option, +} + +#[derive(Insertable)] +#[table_name = "character"] +pub struct NewCharacter<'a> { + pub player_uuid: &'a str, + pub alias: &'a str, + pub tool: Option<&'a str>, +} + +impl From<&Character> for CharacterData { + fn from(character: &Character) -> CharacterData { + CharacterData { + id: Some(character.id), + alias: String::from(&character.alias), + tool: character.tool.clone(), + } + } +} + +/// `Body` represents the body variety for a character +#[derive(Associations, Identifiable, Queryable, Debug, Insertable)] +#[belongs_to(Character)] +#[primary_key(character_id)] +#[table_name = "body"] +pub struct Body { + pub character_id: i32, + pub race: i16, + pub body_type: i16, + pub hair_style: i16, + pub beard: i16, + pub eyebrows: i16, + pub accessory: i16, + pub hair_color: i16, + pub skin: i16, + pub eye_color: i16, +} + +impl From<&Body> for comp::Body { + fn from(body: &Body) -> comp::Body { + comp::Body::Humanoid(comp::humanoid::Body { + race: comp::humanoid::ALL_RACES[body.race as usize], + body_type: comp::humanoid::ALL_BODY_TYPES[body.body_type as usize], + hair_style: body.hair_style as u8, + beard: body.beard as u8, + eyebrows: body.eyebrows as u8, + accessory: body.accessory as u8, + hair_color: body.hair_color as u8, + skin: body.skin as u8, + eye_color: body.eye_color as u8, + }) + } +} diff --git a/server/src/persistence/schema.rs b/server/src/persistence/schema.rs new file mode 100644 index 0000000000..36a3f975d4 --- /dev/null +++ b/server/src/persistence/schema.rs @@ -0,0 +1,27 @@ +table! { + body (character_id) { + character_id -> Integer, + race -> SmallInt, + body_type -> SmallInt, + hair_style -> SmallInt, + beard -> SmallInt, + eyebrows -> SmallInt, + accessory -> SmallInt, + hair_color -> SmallInt, + skin -> SmallInt, + eye_color -> SmallInt, + } +} + +table! { + character (id) { + id -> Integer, + player_uuid -> Text, + alias -> Text, + tool -> Nullable, + } +} + +joinable!(body -> character (character_id)); + +allow_tables_to_appear_in_same_query!(body, character,); diff --git a/server/src/sys/message.rs b/server/src/sys/message.rs index c43d565dbf..1f8531f9c4 100644 --- a/server/src/sys/message.rs +++ b/server/src/sys/message.rs @@ -1,5 +1,5 @@ use super::SysTimer; -use crate::{auth_provider::AuthProvider, client::Client, CLIENT_TIMEOUT}; +use crate::{auth_provider::AuthProvider, client::Client, persistence, CLIENT_TIMEOUT}; use common::{ comp::{Admin, CanBuild, ControlEvent, Controller, ForceUpdate, Ori, Player, Pos, Stats, Vel}, event::{EventBus, ServerEvent}, @@ -134,7 +134,7 @@ impl<'a> System<'a> for Sys { Ok((username, uuid)) => (username, uuid), }; - let player = Player::new(username, view_distance, uuid); + let player = Player::new(username, None, view_distance, uuid); if !player.is_valid() { // Invalid player @@ -154,6 +154,7 @@ impl<'a> System<'a> for Sys { client.notify(ServerMsg::PlayerListUpdate(PlayerListUpdate::Init( player_list.clone(), ))); + // Add to list to notify all clients of the new player new_players.push(entity); }, @@ -174,12 +175,11 @@ impl<'a> System<'a> for Sys { // Become Registered first. ClientState::Connected => client.error_state(RequestStateError::Impossible), ClientState::Registered | ClientState::Spectator => { - if let (Some(player), false) = ( - players.get(entity), - // Only send login message if it wasn't already sent - // previously - client.login_msg_sent, - ) { + // Only send login message if it wasn't already + // sent previously + if let (Some(player), false) = + (players.get(entity), client.login_msg_sent) + { new_chat_msgs.push(( None, ServerMsg::broadcast(format!( @@ -187,10 +187,11 @@ impl<'a> System<'a> for Sys { &player.alias )), )); + client.login_msg_sent = true; } - server_emitter.emit(ServerEvent::CreateCharacter { + server_emitter.emit(ServerEvent::SelectCharacter { entity, name, body, @@ -308,6 +309,55 @@ impl<'a> System<'a> for Sys { ClientMsg::Terminate => { server_emitter.emit(ServerEvent::ClientDisconnect(entity)); }, + ClientMsg::RequestCharacterList => { + if let Some(player) = players.get(entity) { + match persistence::character::load_characters( + &player.uuid().to_string(), + ) { + Ok(character_list) => { + client.notify(ServerMsg::CharacterListUpdate(character_list)); + }, + Err(error) => { + client + .notify(ServerMsg::CharacterActionError(error.to_string())); + }, + } + } + }, + ClientMsg::CreateCharacter { alias, tool, body } => { + if let Some(player) = players.get(entity) { + match persistence::character::create_character( + &player.uuid().to_string(), + alias, + tool, + &body, + ) { + Ok(character_list) => { + client.notify(ServerMsg::CharacterListUpdate(character_list)); + }, + Err(error) => { + client + .notify(ServerMsg::CharacterActionError(error.to_string())); + }, + } + } + }, + ClientMsg::DeleteCharacter(character_id) => { + if let Some(player) = players.get(entity) { + match persistence::character::delete_character( + &player.uuid().to_string(), + character_id, + ) { + Ok(character_list) => { + client.notify(ServerMsg::CharacterListUpdate(character_list)); + }, + Err(error) => { + client + .notify(ServerMsg::CharacterActionError(error.to_string())); + }, + } + } + }, } } } diff --git a/voxygen/src/lib.rs b/voxygen/src/lib.rs index 7737fa8d9e..f57bfdc35e 100644 --- a/voxygen/src/lib.rs +++ b/voxygen/src/lib.rs @@ -15,7 +15,6 @@ pub mod key_state; pub mod logging; pub mod menu; pub mod mesh; -pub mod meta; pub mod render; pub mod scene; pub mod session; @@ -27,15 +26,11 @@ pub mod window; // Reexports pub use crate::error::Error; -use crate::{ - audio::AudioFrontend, meta::Meta, settings::Settings, singleplayer::Singleplayer, - window::Window, -}; +use crate::{audio::AudioFrontend, settings::Settings, singleplayer::Singleplayer, window::Window}; /// A type used to store state that is shared between all play states. pub struct GlobalState { pub settings: Settings, - pub meta: Meta, pub window: Window, pub audio: AudioFrontend, pub info_message: Option, diff --git a/voxygen/src/main.rs b/voxygen/src/main.rs index b4b400aeac..ec17079329 100644 --- a/voxygen/src/main.rs +++ b/voxygen/src/main.rs @@ -7,7 +7,6 @@ use veloren_voxygen::{ i18n::{self, i18n_asset_key, VoxygenLocalization}, logging, menu::main::MainMenuState, - meta::Meta, settings::{AudioOutput, Settings}, window::Window, Direction, GlobalState, PlayState, PlayStateResult, @@ -39,9 +38,6 @@ fn main() { logging::init(&settings, term_log_level, file_log_level); - // Load metadata - let meta = Meta::load(); - // Save settings to add new fields or create the file if it is not already there if let Err(err) = settings.save_to_file() { panic!("Failed to save settings: {:?}", err); @@ -62,7 +58,6 @@ fn main() { audio, window: Window::new(&settings).expect("Failed to create window!"), settings, - meta, info_message: None, singleplayer: None, }; @@ -221,7 +216,6 @@ fn main() { } } - // Save any unsaved changes to settings and meta + // Save any unsaved changes to settings global_state.settings.save_to_file_warn(); - global_state.meta.save_to_file_warn(); } diff --git a/voxygen/src/menu/char_selection/mod.rs b/voxygen/src/menu/char_selection/mod.rs index 558f5ff527..e33d905191 100644 --- a/voxygen/src/menu/char_selection/mod.rs +++ b/voxygen/src/menu/char_selection/mod.rs @@ -39,6 +39,9 @@ impl PlayState for CharSelectionState { // Set up an fps clock. let mut clock = Clock::start(); + // Load the player's character list + self.client.borrow_mut().load_characters(); + let mut current_client_state = self.client.borrow().get_client_state(); while let ClientState::Pending | ClientState::Registered = current_client_state { // Handle window events @@ -62,22 +65,35 @@ impl PlayState for CharSelectionState { // Maintain the UI. let events = self .char_selection_ui - .maintain(global_state, &self.client.borrow()); + .maintain(global_state, &mut self.client.borrow_mut()); + for event in events { match event { ui::Event::Logout => { return PlayStateResult::Pop; }, + ui::Event::AddCharacter { alias, tool, body } => { + self.client.borrow_mut().create_character(alias, tool, body); + }, + ui::Event::DeleteCharacter(character_id) => { + self.client.borrow_mut().delete_character(character_id); + }, ui::Event::Play => { let char_data = self .char_selection_ui - .get_character_data() + .get_character_list() .expect("Character data is required to play"); - self.client.borrow_mut().request_character( - char_data.name, - char_data.body, - char_data.tool, - ); + + if let Some(selected_character) = + char_data.get(self.char_selection_ui.selected_character) + { + self.client.borrow_mut().request_character( + selected_character.character.alias.clone(), + selected_character.body, + selected_character.character.tool.clone(), + ); + } + return PlayStateResult::Switch(Box::new(SessionState::new( global_state, self.client.clone(), @@ -91,10 +107,16 @@ impl PlayState for CharSelectionState { let humanoid_body = self .char_selection_ui - .get_character_data() - .and_then(|data| match data.body { - comp::Body::Humanoid(body) => Some(body), - _ => None, + .get_character_list() + .and_then(|data| { + if let Some(character) = data.get(self.char_selection_ui.selected_character) { + match character.body { + comp::Body::Humanoid(body) => Some(body), + _ => None, + } + } else { + None + } }); // Maintain the scene. diff --git a/voxygen/src/menu/char_selection/ui.rs b/voxygen/src/menu/char_selection/ui.rs index 6dbe44f3cb..8587b59679 100644 --- a/voxygen/src/menu/char_selection/ui.rs +++ b/voxygen/src/menu/char_selection/ui.rs @@ -1,6 +1,5 @@ use crate::{ i18n::{i18n_asset_key, VoxygenLocalization}, - meta::CharacterData, render::{Consts, Globals, Renderer}, ui::{ fonts::ConrodVoxygenFonts, @@ -14,6 +13,7 @@ use client::Client; use common::{ assets, assets::{load, load_expect}, + character::{Character, CharacterItem, MAX_CHARACTERS_PER_PLAYER}, comp::{self, humanoid}, }; use conrod_core::{ @@ -65,6 +65,10 @@ widget_ids! { info_no, delete_text, space, + loading_characters_text, + creating_character_text, + deleting_character_text, + character_error_message, // REMOVE THIS AFTER IMPLEMENTATION daggers_grey, @@ -244,19 +248,41 @@ rotation_image_ids! { pub enum Event { Logout, Play, + AddCharacter { + alias: String, + tool: Option, + body: comp::Body, + }, + DeleteCharacter(i32), } const TEXT_COLOR: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); const TEXT_COLOR_2: Color = Color::Rgba(1.0, 1.0, 1.0, 0.2); +#[derive(PartialEq)] enum InfoContent { None, Deletion(usize), - //Name, + LoadingCharacters, + CreatingCharacter, + DeletingCharacter, + CharacterError, +} + +impl InfoContent { + pub fn has_content(&self, character_list_loading: &bool) -> bool { + match self { + Self::None => false, + Self::CreatingCharacter | Self::DeletingCharacter | Self::LoadingCharacters => { + *character_list_loading + }, + _ => true, + } + } } pub enum Mode { - Select(Option), + Select(Option>), Create { name: String, body: humanoid::Body, @@ -271,16 +297,10 @@ pub struct CharSelectionUi { imgs: Imgs, rot_imgs: ImgsRot, fonts: ConrodVoxygenFonts, - //character_creation: bool, info_content: InfoContent, voxygen_i18n: Arc, - //deletion_confirmation: bool, - /* - pub character_name: String, - pub character_body: humanoid::Body, - pub character_tool: Option<&'static str>, - */ pub mode: Mode, + pub selected_character: usize, } impl CharSelectionUi { @@ -303,83 +323,86 @@ impl CharSelectionUi { let fonts = ConrodVoxygenFonts::load(&voxygen_i18n.fonts, &mut ui) .expect("Impossible to load fonts!"); - // TODO: Randomize initial values. Self { ui, ids, imgs, rot_imgs, fonts, - info_content: InfoContent::None, + info_content: InfoContent::LoadingCharacters, + selected_character: 0, voxygen_i18n, - //deletion_confirmation: false, - /* - character_creation: false, - selected_language: global_state.settings.language.selected_language.clone(), - character_name: "Character Name".to_string(), - character_body: humanoid::Body::random(), - character_tool: Some(STARTER_SWORD), - */ mode: Mode::Select(None), } } - pub fn get_character_data(&self) -> Option { + pub fn get_character_list(&self) -> Option> { match &self.mode { Mode::Select(data) => data.clone(), Mode::Create { name, body, tool, .. - } => Some(CharacterData { - name: name.clone(), + } => Some(vec![CharacterItem { + character: Character { + id: None, + alias: name.clone(), + tool: tool.map(|specifier| specifier.to_string()), + }, body: comp::Body::Humanoid(body.clone()), - tool: tool.map(|specifier| specifier.to_string()), - }), + }]), } } pub fn get_loadout(&mut self) -> Option { match &mut self.mode { - Mode::Select(characterdata) => { - let loadout = comp::Loadout { - active_item: characterdata - .as_ref() - .and_then(|d| d.tool.as_ref()) - .map(|tool| comp::ItemConfig { - item: (*load::(&tool).unwrap_or_else(|err| { - error!( - "Could not load item {} maybe it no longer exists: {:?}", - &tool, err - ); - load_expect("common.items.weapons.sword.starter_sword") - })) - .clone(), - ability1: None, - ability2: None, - ability3: None, - block_ability: None, - dodge_ability: None, - }), - second_item: None, - shoulder: None, - chest: Some(assets::load_expect_cloned( - "common.items.armor.starter.rugged_chest", - )), - belt: None, - hand: None, - pants: Some(assets::load_expect_cloned( - "common.items.armor.starter.rugged_pants", - )), - foot: Some(assets::load_expect_cloned( - "common.items.armor.starter.sandals_0", - )), - back: None, - ring: None, - neck: None, - lantern: None, - head: None, - tabard: None, - }; - Some(loadout) + Mode::Select(character_list) => { + if let Some(data) = character_list { + if let Some(character_item) = data.get(self.selected_character) { + let loadout = comp::Loadout { + active_item: character_item.character.tool.as_ref().map(|tool| { + comp::ItemConfig { + item: (*load::(&tool).unwrap_or_else(|err| { + error!( + "Could not load item {} maybe it no longer exists: \ + {:?}", + &tool, err + ); + load_expect("common.items.weapons.sword.starter_sword") + })) + .clone(), + ability1: None, + ability2: None, + ability3: None, + block_ability: None, + dodge_ability: None, + } + }), + second_item: None, + shoulder: None, + chest: Some(assets::load_expect_cloned( + "common.items.armor.starter.rugged_chest", + )), + belt: None, + hand: None, + pants: Some(assets::load_expect_cloned( + "common.items.armor.starter.rugged_pants", + )), + foot: Some(assets::load_expect_cloned( + "common.items.armor.starter.sandals_0", + )), + back: None, + ring: None, + neck: None, + lantern: None, + head: None, + tabard: None, + }; + Some(loadout) + } else { + None + } + } else { + None + } }, Mode::Create { loadout, tool, .. } => { loadout.active_item = tool.map(|tool| comp::ItemConfig { @@ -405,7 +428,7 @@ impl CharSelectionUi { } // TODO: Split this into multiple modules or functions. - fn update_layout(&mut self, global_state: &mut GlobalState, client: &Client) -> Vec { + fn update_layout(&mut self, client: &mut Client) -> Vec { let mut events = Vec::new(); let can_enter_world = match &self.mode { @@ -453,9 +476,16 @@ impl CharSelectionUi { .title_text_color(TEXT_COLOR) .desc_text_color(TEXT_COLOR_2); + // Set the info content if we encountered an error related to characters + if client.character_list.error.is_some() { + self.info_content = InfoContent::CharacterError; + } + // Information Window - if let InfoContent::None = self.info_content { - } else { + if self + .info_content + .has_content(&client.character_list.loading) + { Rectangle::fill_with([520.0, 150.0], color::rgba(0.0, 0.0, 0.0, 0.9)) .mid_top_with_margin_on(ui_widgets.window, 300.0) .set(self.ids.info_bg, ui_widgets); @@ -467,6 +497,7 @@ impl CharSelectionUi { Rectangle::fill_with([275.0, 150.0], color::TRANSPARENT) .bottom_left_with_margins_on(self.ids.info_frame, 0.0, 0.0) .set(self.ids.info_button_align, ui_widgets); + match self.info_content { InfoContent::None => unreachable!(), InfoContent::Deletion(character_index) => { @@ -505,21 +536,90 @@ impl CharSelectionUi { .was_clicked() { self.info_content = InfoContent::None; - global_state.meta.delete_character(character_index); + + if let Some(character_item) = + client.character_list.characters.get(character_index) + { + // Unsaved characters have no id, this should never be the case here + if let Some(character_id) = character_item.character.id { + self.info_content = InfoContent::DeletingCharacter; + + events.push(Event::DeleteCharacter(character_id)); + } + } }; }, + InfoContent::LoadingCharacters => { + Text::new(&self.voxygen_i18n.get("char_selection.loading_characters")) + .mid_top_with_margin_on(self.ids.info_frame, 40.0) + .font_size(self.fonts.cyri.scale(24)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(self.ids.loading_characters_text, ui_widgets); + }, + InfoContent::CreatingCharacter => { + Text::new(&self.voxygen_i18n.get("char_selection.creating_character")) + .mid_top_with_margin_on(self.ids.info_frame, 40.0) + .font_size(self.fonts.cyri.scale(24)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(self.ids.creating_character_text, ui_widgets); + }, + InfoContent::DeletingCharacter => { + Text::new(&self.voxygen_i18n.get("char_selection.deleting_character")) + .mid_top_with_margin_on(self.ids.info_frame, 40.0) + .font_size(self.fonts.cyri.scale(24)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(self.ids.deleting_character_text, ui_widgets); + }, + InfoContent::CharacterError => { + if let Some(error_message) = &client.character_list.error { + Text::new(&format!( + "{}: {}", + &self.voxygen_i18n.get("common.error"), + error_message + )) + .mid_top_with_margin_on(self.ids.info_frame, 40.0) + .font_size(self.fonts.cyri.scale(24)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(self.ids.character_error_message, ui_widgets); + + if Button::image(self.imgs.button) + .w_h(150.0, 40.0) + .bottom_right_with_margins_on(self.ids.info_button_align, 20.0, 20.0) + .hover_image(self.imgs.button_hover) + .press_image(self.imgs.button_press) + .label_y(Relative::Scalar(2.0)) + .label(&self.voxygen_i18n.get("common.close")) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(18)) + .label_color(TEXT_COLOR) + .set(self.ids.info_ok, ui_widgets) + .was_clicked() + { + self.info_content = InfoContent::None; + client.character_list.error = None; + } + } else { + self.info_content = InfoContent::None; + } + }, } } + // Character Selection ///////////////// match &mut self.mode { Mode::Select(data) => { // Set active body - *data = if let Some(character) = global_state - .meta + *data = if client + .character_list .characters - .get(global_state.meta.selected_character) + .get(self.selected_character) + .is_some() { - Some(character.clone()) + Some(client.character_list.characters.clone()) } else { None }; @@ -579,7 +679,7 @@ impl CharSelectionUi { } // Enter World Button - let character_count = global_state.meta.characters.len(); + let character_count = client.character_list.characters.len(); let enter_world_str = &self.voxygen_i18n.get("char_selection.enter_world"); let enter_button = Button::image(self.imgs.button) .mid_bottom_with_margin_on(ui_widgets.window, 10.0) @@ -648,13 +748,12 @@ impl CharSelectionUi { .resize(character_count, &mut ui_widgets.widget_id_generator()); // Character selection - for (i, character) in global_state.meta.characters.iter().enumerate() { - let character_box = - Button::image(if global_state.meta.selected_character == i { - self.imgs.selection_hover - } else { - self.imgs.selection - }); + for (i, character_item) in client.character_list.characters.iter().enumerate() { + let character_box = Button::image(if self.selected_character == i { + self.imgs.selection_hover + } else { + self.imgs.selection + }); let character_box = if i == 0 { character_box.top_left_with_margins_on( self.ids.charlist_alignment, @@ -674,7 +773,7 @@ impl CharSelectionUi { .set(self.ids.character_boxes[i], ui_widgets) .was_clicked() { - global_state.meta.selected_character = i; + self.selected_character = i; } if Button::image(self.imgs.delete_button) .w_h(30.0 * 0.5, 30.0 * 0.5) @@ -692,7 +791,7 @@ impl CharSelectionUi { { self.info_content = InfoContent::Deletion(i); } - Text::new(&character.name) + Text::new(&character_item.character.alias) .top_left_with_margins_on(self.ids.character_boxes[i], 6.0, 9.0) .font_size(self.fonts.cyri.scale(19)) .font_id(self.fonts.cyri.conrod_id) @@ -731,23 +830,34 @@ impl CharSelectionUi { 2.0, ) }; + + let character_limit_reached = character_count >= MAX_CHARACTERS_PER_PLAYER; + + let color = if character_limit_reached { + Color::Rgba(0.38, 0.38, 0.10, 1.0) + } else { + Color::Rgba(0.38, 1.0, 0.07, 1.0) + }; + if create_char_button .w_h(386.0, 80.0) .hover_image(self.imgs.selection_hover) .press_image(self.imgs.selection_press) .label(&self.voxygen_i18n.get("char_selection.create_new_charater")) - .label_color(Color::Rgba(0.38, 1.0, 0.07, 1.0)) + .label_color(color) .label_font_id(self.fonts.cyri.conrod_id) - .image_color(Color::Rgba(0.38, 1.0, 0.07, 1.0)) + .image_color(color) .set(self.ids.character_box_2, ui_widgets) .was_clicked() { - self.mode = Mode::Create { - name: "Character Name".to_string(), - body: humanoid::Body::random(), - loadout: comp::Loadout::default(), - tool: Some(STARTER_SWORD), - }; + if !character_limit_reached { + self.mode = Mode::Create { + name: "Character Name".to_string(), + body: humanoid::Body::random(), + loadout: comp::Loadout::default(), + tool: Some(STARTER_SWORD), + }; + } } }, // Character_Creation @@ -791,12 +901,14 @@ impl CharSelectionUi { .set(self.ids.create_button, ui_widgets) .was_clicked() { - global_state.meta.selected_character = - global_state.meta.add_character(CharacterData { - name: name.clone(), - body: comp::Body::Humanoid(body.clone()), - tool: tool.map(|tool| tool.to_string()), - }); + self.info_content = InfoContent::CreatingCharacter; + + events.push(Event::AddCharacter { + alias: name.clone(), + tool: tool.map(|tool| tool.to_string()), + body: comp::Body::Humanoid(body.clone()), + }); + to_select = true; } // Character Name Input @@ -1290,20 +1402,16 @@ impl CharSelectionUi { body.skin = new_val as u8; } // Eyebrows - let current_eyebrows = body.eyebrows; if let Some(new_val) = char_slider( self.ids.skin_slider, self.voxygen_i18n.get("char_selection.eyebrows"), self.ids.eyebrows_text, - humanoid::ALL_EYEBROWS.len() - 1, - humanoid::ALL_EYEBROWS - .iter() - .position(|&c| c == current_eyebrows) - .unwrap_or(0), + body.race.num_eyebrows(body.body_type) as usize - 1, + body.eyebrows as usize, self.ids.eyebrows_slider, ui_widgets, ) { - body.eyebrows = humanoid::ALL_EYEBROWS[new_val]; + body.eyebrows = new_val as u8; } // EyeColor if let Some(new_val) = char_slider( @@ -1423,8 +1531,8 @@ impl CharSelectionUi { } } - pub fn maintain(&mut self, global_state: &mut GlobalState, client: &Client) -> Vec { - let events = self.update_layout(global_state, client); + pub fn maintain(&mut self, global_state: &mut GlobalState, client: &mut Client) -> Vec { + let events = self.update_layout(client); self.ui.maintain(global_state.window.renderer_mut(), None); events } diff --git a/voxygen/src/scene/figure/load.rs b/voxygen/src/scene/figure/load.rs index 17876a8dfb..3b1debba8f 100644 --- a/voxygen/src/scene/figure/load.rs +++ b/voxygen/src/scene/figure/load.rs @@ -8,7 +8,7 @@ use common::{ critter::{BodyType as CBodyType, Species as CSpecies}, dragon, fish_medium, fish_small, golem::{BodyType as GBodyType, Species as GSpecies}, - humanoid::{Body, BodyType, EyeColor, Eyebrows, Race, Skin}, + humanoid::{Body, BodyType, EyeColor, Race, Skin}, item::{ armor::{Armor, Back, Belt, Chest, Foot, Hand, Head, Pants, Shoulder, Tabard}, tool::{Tool, ToolKind}, @@ -156,7 +156,7 @@ impl HumHeadSpec { beard: u8, eye_color: u8, skin: u8, - _eyebrows: Eyebrows, + _eyebrows: u8, accessory: u8, generate_mesh: impl FnOnce(&Segment, Vec3) -> Mesh, ) -> Mesh {