diff --git a/Cargo.lock b/Cargo.lock index 0b4381092..6e1986871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -713,6 +713,15 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "enable-ansi-support" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d29d3ca9ba14c336417f8d7bc7f373e8c16d10c30cc0794b09d3cecab631ab" +dependencies = [ + "winapi", +] + [[package]] name = "enumset" version = "1.0.8" @@ -939,6 +948,7 @@ dependencies = [ "base64ct", "colored", "crossbeam-utils", + "enable-ansi-support", "feather-base", "feather-common", "feather-ecs", @@ -953,6 +963,7 @@ dependencies = [ "hematite-nbt", "libcraft-core", "libcraft-items", + "libcraft-text", "log", "md-5", "num-bigint", @@ -1430,6 +1441,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "string-builder", "thiserror", "uuid", ] @@ -2769,6 +2781,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "string-builder" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd10a070fb1f2796a288abec42695db4682a82b6f12ffacd60fb8d5ad3a4a12" + [[package]] name = "strsim" version = "0.10.0" diff --git a/README.md b/README.md index aa6cfef37..c904c8a24 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Feather + [![build](https://github.com/feather-rs/feather/workflows/build/badge.svg)](https://github.com/feather-rs/feather/actions) [![Discord](https://img.shields.io/discord/619316022800809995?logo=discord)](https://discordapp.com/invite/4eYmK69) - A Minecraft server implementation written in Rust. ### Status diff --git a/feather/protocol/src/packets/server/play.rs b/feather/protocol/src/packets/server/play.rs index e52266b58..97f636f6e 100644 --- a/feather/protocol/src/packets/server/play.rs +++ b/feather/protocol/src/packets/server/play.rs @@ -370,7 +370,7 @@ pub enum GameStateChange { WinGame { show_credits: bool, }, - /// See https://help.minecraft.net/hc/en-us/articles/4408948974989-Minecraft-Java-Edition-Demo-Mode- + /// See DemoEvent(DemoEventType), /// Sent when any player is struck by an arrow. ArrowHitAnyPlayer, diff --git a/feather/server/Cargo.toml b/feather/server/Cargo.toml index 970560311..5aa655cf8 100644 --- a/feather/server/Cargo.toml +++ b/feather/server/Cargo.toml @@ -22,6 +22,7 @@ colored = "2" common = { path = "../common", package = "feather-common" } crossbeam-utils = "0.8" ecs = { path = "../ecs", package = "feather-ecs" } +enable-ansi-support = "0.1.2" fern = "0.6" flate2 = "1" flume = "0.10" @@ -55,6 +56,7 @@ uuid = "0.8" slab = "0.4" libcraft-core = { path = "../../libcraft/core" } libcraft-items = { path = "../../libcraft/items" } +libcraft-text = { path = "../../libcraft/text" } worldgen = { path = "../worldgen", package = "feather-worldgen" } [features] diff --git a/feather/server/src/main.rs b/feather/server/src/main.rs index c3bf7ea16..1b0f0eca5 100644 --- a/feather/server/src/main.rs +++ b/feather/server/src/main.rs @@ -15,6 +15,8 @@ const CONFIG_PATH: &str = "config.toml"; #[tokio::main] async fn main() -> anyhow::Result<()> { + enable_ansi(); + let feather_server::config::ConfigContainer { config, was_config_created, @@ -36,6 +38,15 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn enable_ansi() { + match enable_ansi_support::enable_ansi_support() { + Ok(()) => {} + Err(_) => { + log::warn!("Failed to enable ANSI support, Output will not work properly"); + } + } +} + fn init_game(server: Server, config: &Config) -> anyhow::Result { let mut game = Game::new(); init_systems(&mut game, server); diff --git a/feather/server/src/systems/chat.rs b/feather/server/src/systems/chat.rs index d19aa64ed..ddb9e73f9 100644 --- a/feather/server/src/systems/chat.rs +++ b/feather/server/src/systems/chat.rs @@ -37,8 +37,7 @@ fn flush_chat_boxes(game: &mut Game, server: &mut Server) -> SysResult { fn flush_console_chat_box(game: &mut Game) -> SysResult { for (_, (_console, mailbox)) in game.ecs.query::<(&Console, &mut ChatBox)>().iter() { for message in mailbox.drain() { - // TODO: properly display chat message - log::info!("{:?}", message.text()); + log::info!("{}", message.text().as_ansi()); } } @@ -56,3 +55,15 @@ fn flush_title_chat_boxes(game: &mut Game, server: &mut Server) -> SysResult { Ok(()) } + +#[cfg(test)] +mod tests { + use libcraft_text::{TextComponent, TextComponentBuilder}; + + #[test] + fn test_ansi_text_serialization() { + let text = TextComponent::from("Hello, world!").red().bold(); + let ansi_text = text.as_ansi(); + assert_eq!("\x1b[1;31mHello, world!\x1b[0m", ansi_text); + } +} diff --git a/feather/server/src/systems/player_join.rs b/feather/server/src/systems/player_join.rs index f8e01b195..9d7755632 100644 --- a/feather/server/src/systems/player_join.rs +++ b/feather/server/src/systems/player_join.rs @@ -1,4 +1,5 @@ use libcraft_items::InventorySlot; +use libcraft_text::{IntoTextComponent, TextComponentBuilder}; use log::debug; use base::anvil::player::PlayerAbilities; @@ -143,7 +144,8 @@ fn accept_new_player(game: &mut Game, server: &mut Server, client_id: ClientId) fn broadcast_player_join(game: &mut Game, username: &str) { let message = Text::translate_with("multiplayer.player.joined", vec![username.to_owned()]); - game.broadcast_chat(ChatKind::System, message); + let component = message.into_component().yellow(); + game.broadcast_chat(ChatKind::System, component); } fn player_abilities_or_default( diff --git a/libcraft/items/src/item_stack.rs b/libcraft/items/src/item_stack.rs index 410e6650d..d1ec8b031 100644 --- a/libcraft/items/src/item_stack.rs +++ b/libcraft/items/src/item_stack.rs @@ -366,7 +366,15 @@ pub enum ItemStackError { impl Display for ItemStackError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) + let name = match self { + ItemStackError::ClientOverflow => "Client overflow", + ItemStackError::EmptyStack => "Empty stack", + ItemStackError::ExceedsStackSize => "Exceeds stack size", + ItemStackError::IncompatibleStacks => "Incompatible stacks", + ItemStackError::NotEnoughItems => "Not enough items", + }; + + write!(f, "{}", name) } } diff --git a/libcraft/text/Cargo.toml b/libcraft/text/Cargo.toml index b99bd785c..50b6f3e42 100644 --- a/libcraft/text/Cargo.toml +++ b/libcraft/text/Cargo.toml @@ -13,3 +13,4 @@ serde_json = "1" serde_with = "1" uuid = { version = "0.8", features = [ "serde" ] } thiserror = "1" +string-builder = "0.2.0" \ No newline at end of file diff --git a/libcraft/text/src/ansi.rs b/libcraft/text/src/ansi.rs new file mode 100644 index 000000000..c5ddc4fba --- /dev/null +++ b/libcraft/text/src/ansi.rs @@ -0,0 +1,97 @@ +pub struct AnsiStyle { + format: u8, +} + +impl AnsiStyle { + pub fn regular() -> AnsiStyle { + AnsiStyle { format: 0 } + } + + pub fn bold() -> AnsiStyle { + AnsiStyle { format: 1 } + } + + pub fn underline() -> AnsiStyle { + AnsiStyle { format: 4 } + } + + fn format(&self, color: u8) -> String { + format!("\x1B[{};{}m", self.format, color) + } + + pub fn black(&self) -> String { + self.format(30) + } + + pub fn red(&self) -> String { + self.format(31) + } + + pub fn green(&self) -> String { + self.format(32) + } + + pub fn yellow(&self) -> String { + self.format(33) + } + + pub fn blue(&self) -> String { + self.format(34) + } + + pub fn magenta(&self) -> String { + self.format(35) + } + + pub fn cyan(&self) -> String { + self.format(36) + } + + pub fn white(&self) -> String { + self.format(37) + } + + pub fn reset() -> &'static str { + "\x1b[0m" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ansi_style() { + assert_eq!(AnsiStyle::reset(), "\x1b[0m"); + + let style = AnsiStyle::regular(); + assert_eq!(style.black(), "\x1b[0;30m"); + assert_eq!(style.red(), "\x1b[0;31m"); + assert_eq!(style.green(), "\x1b[0;32m"); + assert_eq!(style.yellow(), "\x1b[0;33m"); + assert_eq!(style.blue(), "\x1b[0;34m"); + assert_eq!(style.magenta(), "\x1b[0;35m"); + assert_eq!(style.cyan(), "\x1b[0;36m"); + assert_eq!(style.white(), "\x1b[0;37m"); + + let style = AnsiStyle::bold(); + assert_eq!(style.black(), "\x1b[1;30m"); + assert_eq!(style.red(), "\x1b[1;31m"); + assert_eq!(style.green(), "\x1b[1;32m"); + assert_eq!(style.yellow(), "\x1b[1;33m"); + assert_eq!(style.blue(), "\x1b[1;34m"); + assert_eq!(style.magenta(), "\x1b[1;35m"); + assert_eq!(style.cyan(), "\x1b[1;36m"); + assert_eq!(style.white(), "\x1b[1;37m"); + + let style = AnsiStyle::underline(); + assert_eq!(style.black(), "\x1b[4;30m"); + assert_eq!(style.red(), "\x1b[4;31m"); + assert_eq!(style.green(), "\x1b[4;32m"); + assert_eq!(style.yellow(), "\x1b[4;33m"); + assert_eq!(style.blue(), "\x1b[4;34m"); + assert_eq!(style.magenta(), "\x1b[4;35m"); + assert_eq!(style.cyan(), "\x1b[4;36m"); + assert_eq!(style.white(), "\x1b[4;37m"); + } +} diff --git a/libcraft/text/src/lib.rs b/libcraft/text/src/lib.rs index 672851c8c..e6493fc34 100644 --- a/libcraft/text/src/lib.rs +++ b/libcraft/text/src/lib.rs @@ -1,3 +1,4 @@ +pub mod ansi; pub mod text; pub mod title; diff --git a/libcraft/text/src/text.rs b/libcraft/text/src/text.rs index 90af776de..3c3543bf3 100644 --- a/libcraft/text/src/text.rs +++ b/libcraft/text/src/text.rs @@ -1,5 +1,6 @@ //! Implementation of the Minecraft chat component format. +use crate::ansi::AnsiStyle; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use std::borrow::Cow; use std::fmt::{self, Display, Formatter}; @@ -146,6 +147,47 @@ impl From for Text { } } +impl From for String { + fn from(bind: Keybind) -> Self { + match bind { + Keybind::Attack => "attack".to_string(), + Keybind::UseItem => "use".to_string(), + Keybind::Forward => "forward".to_string(), + Keybind::Left => "left".to_string(), + Keybind::Back => "back".to_string(), + Keybind::Right => "right".to_string(), + Keybind::Jump => "jump".to_string(), + Keybind::Sneak => "sneak".to_string(), + Keybind::Sprint => "sprint".to_string(), + Keybind::Drop => "drop".to_string(), + Keybind::Inventory => "inventory".to_string(), + Keybind::Chat => "chat".to_string(), + Keybind::ListPlayers => "list_players".to_string(), + Keybind::PickBlock => "pick_block".to_string(), + Keybind::Command => "command".to_string(), + Keybind::Screenshot => "screenshot".to_string(), + Keybind::Perspective => "perspective".to_string(), + Keybind::MouseSmoothing => "mouse_smoothing".to_string(), + Keybind::Fullscreen => "fullscreen".to_string(), + Keybind::SpectatorOutlines => "spectator_outlines".to_string(), + Keybind::SwapHands => "swap_hands".to_string(), + Keybind::SaveToolbar => "save_toolbar".to_string(), + Keybind::LoadToolbar => "load_toolbar".to_string(), + Keybind::Advancements => "advancements".to_string(), + Keybind::Hotbar1 => "hotbar1".to_string(), + Keybind::Hotbar2 => "hotbar2".to_string(), + Keybind::Hotbar3 => "hotbar3".to_string(), + Keybind::Hotbar4 => "hotbar4".to_string(), + Keybind::Hotbar5 => "hotbar5".to_string(), + Keybind::Hotbar6 => "hotbar6".to_string(), + Keybind::Hotbar7 => "hotbar7".to_string(), + Keybind::Hotbar8 => "hotbar8".to_string(), + Keybind::Hotbar9 => "hotbar9".to_string(), + Keybind::Custom(s) => s.to_string(), + } + } +} + impl Serialize for Keybind { fn serialize(&self, serializer: S) -> Result where @@ -257,9 +299,40 @@ impl From<&Keybind> for String { pub enum Translate { ChatTypeText, MultiplayerPlayerJoined, + MultiplayerPlayerLeft, Custom(Cow<'static, str>), } +impl Translate { + fn to_text(&self, args: &[Text], style: &str) -> String { + match self { + Translate::ChatTypeText => { + format!("<{}{}> {}", args[0].as_ansi(), style, args[1].as_ansi()) + } + Translate::MultiplayerPlayerJoined => { + format!("{}{} joined the game", args[0].as_ansi(), style) + } + Translate::MultiplayerPlayerLeft => { + format!("{}{} left the game", args[0].as_ansi(), style) + } + Translate::Custom(name) => { + let mut args_strings: String = String::new(); + + for arg in args { + args_strings = format!("{} '{}{}'", args_strings, arg.as_ansi(), style); + } + + args_strings.push(' '); + + format!( + "", + name, args_strings + ) + } + } + } +} + impl Serialize for Translate { fn serialize(&self, serializer: S) -> Result where @@ -299,6 +372,7 @@ where match value.as_ref() { "chat.type.text" => Translate::ChatTypeText, "multiplayer.player.joined" => Translate::MultiplayerPlayerJoined, + "multiplayer.player.left" => Translate::MultiplayerPlayerLeft, _ => Translate::Custom(value), } } @@ -309,6 +383,7 @@ impl<'a> From<&Translate> for String { match translate { Translate::ChatTypeText => "chat.type.text", Translate::MultiplayerPlayerJoined => "multiplayer.player.joined", + Translate::MultiplayerPlayerLeft => "multiplayer.player.left", Translate::Custom(key) => key.as_ref(), } .into() @@ -375,6 +450,19 @@ pub enum TextValue { }, } +impl TextValue { + pub fn name_of(&self) -> &'static str { + match self { + TextValue::Text { .. } => "text", + TextValue::Translate { .. } => "translate", + TextValue::Score { .. } => "score", + TextValue::Selector { .. } => "selector", + TextValue::Keybind { .. } => "keybind", + TextValue::Nbt { .. } => "nbt", + } + } +} + impl From for TextValue where T: Into>, @@ -473,6 +561,76 @@ impl TextComponent { pub fn empty() -> TextComponent { TextComponent::from("") } + + pub fn as_ansi(&self) -> String { + let mut style_type = AnsiStyle::regular(); + + if self.bold.unwrap_or(false) { + style_type = AnsiStyle::bold() + } + + if self.underlined.unwrap_or(false) { + style_type = AnsiStyle::underline() + } + + let mut style = style_type.white(); + + if self.color.is_some() { + let color = self.color.as_ref().unwrap(); + + match color { + Color::Black => style = style_type.black(), + Color::DarkGray => style = style_type.black(), + Color::White => style = style_type.white(), + Color::Gray => style = style_type.black(), + Color::Red => style = style_type.red(), + Color::DarkRed => style = style_type.red(), + Color::Gold => style = style_type.yellow(), + Color::Yellow => style = style_type.yellow(), + Color::DarkGreen => style = style_type.green(), + Color::Green => style = style_type.green(), + Color::Aqua => style = style_type.cyan(), + Color::DarkAqua => style = style_type.cyan(), + Color::DarkBlue => style = style_type.blue(), + Color::Blue => style = style_type.blue(), + Color::LightPurple => style = style_type.magenta(), + Color::DarkPurple => style = style_type.magenta(), + Color::Custom(_) => style = style_type.black(), + } + } + + let content = if let TextValue::Text { text } = self.clone().value { + String::from(text) + } else if let TextValue::Translate { translate, with } = self.clone().value { + translate.to_text(&with, &style) + } else if let TextValue::Score { + value, + name, + objective, + } = self.clone().value + { + if value.is_some() { + format!( + "", + name, + objective, + value.unwrap() + ) + } else { + format!("", objective, name) + } + } else if let TextValue::Selector { selector } = self.clone().value { + format!("{}", selector) + } else if let TextValue::Keybind { keybind } = self.clone().value { + format!("", String::from(keybind)) + } else if let TextValue::Nbt { nbt } = self.clone().value { + format!("", nbt) + } else { + panic!("Unsupported TextValue") + }; + + format!("{}{}{}", &style, content, AnsiStyle::reset()) + } } pub enum Reset { @@ -966,6 +1124,24 @@ impl Text { pub fn nbt>(nbt: A) -> Text { Text::from(TextValue::nbt(nbt)) } + + pub fn as_ansi(&self) -> String { + let mut ansi = string_builder::Builder::default(); + + let component = self.clone().into_component(); + ansi.append(component.as_ansi()); + + if component.extra.is_some() { + for extra in component.extra.unwrap() { + ansi.append(extra.as_ansi()); + } + } + + ansi.string().expect(&*format!( + "Failed to convert text to ansi: {}", + &serde_json::to_string(self).unwrap() + )) + } } impl From for String { diff --git a/quill/example-plugins/simple/src/lib.rs b/quill/example-plugins/simple/src/lib.rs index d6ac19dae..a76361d4d 100644 --- a/quill/example-plugins/simple/src/lib.rs +++ b/quill/example-plugins/simple/src/lib.rs @@ -23,14 +23,16 @@ fn test_system(plugin: &mut SimplePlugin, game: &mut Game) { for (entity, (position, name, gamemode, uuid)) in game.query::<(&Position, &Name, &Gamemode, &Uuid)>() { - entity.send_message(format!( - "[{}] Hi {}. Your gamemode is {:?} and your position is {:.1?} and your UUID is {}", - plugin.tick_counter, - name, - gamemode, - position, - uuid.to_hyphenated() - )); + if plugin.tick_counter % 50 == 0 { + entity.send_message(format!( + "[{}] Hi {}. Your gamemode is {:?} and your position is {:.1?} and your UUID is {}", + plugin.tick_counter, + name, + gamemode, + position, + uuid.to_hyphenated() + )); + } if plugin.tick_counter % 100 == 0 { entity.send_message("Spawning a mob on you");