diff --git a/Cargo.toml b/Cargo.toml index f72a1d6..15de2ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ category = "Game" osx_minimum_system_version = "10.12" [features] -default = ["default-base", "backend-sdl", "render-opengl", "exe", "webbrowser"] +default = ["default-base", "backend-sdl", "render-opengl", "exe", "webbrowser", "discord-rpc"] default-base = ["ogg-playback"] ogg-playback = ["lewton"] backend-sdl = ["sdl2", "sdl2-sys"] @@ -45,6 +45,7 @@ backend-glutin = ["winit", "glutin", "render-opengl"] backend-horizon = [] render-opengl = [] scripting-lua = ["lua-ffi"] +discord-rpc = [] netplay = ["serde_cbor"] editor = [] exe = [] @@ -62,6 +63,7 @@ case_insensitive_hashmap = "1.0.0" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } cpal = { git = "https://github.com/doukutsu-rs/cpal", rev = "9d269d8724102404e73a61e9def0c0cbc921b676" } directories = "3" +discord-rich-presence = "0.2" downcast = "0.11" glutin = { git = "https://github.com/doukutsu-rs/glutin.git", rev = "2dd95f042e6e090d36f577cbea125560dd99bd27", optional = true, default_features = false, features = ["x11"] } imgui = "0.8" diff --git a/src/data/builtin/builtin_data/locale/en.json b/src/data/builtin/builtin_data/locale/en.json index 856d984..17961b6 100644 --- a/src/data/builtin/builtin_data/locale/en.json +++ b/src/data/builtin/builtin_data/locale/en.json @@ -126,7 +126,8 @@ "entry": "Cutscene Skip:", "hold": "Hold to Skip", "fastforward": "Fast-Forward" - } + }, + "discord_rpc": "Discord Rich Presence:" }, "links": "Links...", "advanced": "Advanced...", diff --git a/src/data/builtin/builtin_data/locale/jp.json b/src/data/builtin/builtin_data/locale/jp.json index d550ae2..7e263b4 100644 --- a/src/data/builtin/builtin_data/locale/jp.json +++ b/src/data/builtin/builtin_data/locale/jp.json @@ -126,7 +126,8 @@ "entry": "カットシーンをスキップ", "hold": "を押し続け", "fastforward": "はやおくり" - } + }, + "discord_rpc": "Discord Rich Presence:" }, "links": "リンク", "advanced": "詳細設定", diff --git a/src/discord/mod.rs b/src/discord/mod.rs new file mode 100644 index 0000000..cc26cbe --- /dev/null +++ b/src/discord/mod.rs @@ -0,0 +1,121 @@ +use discord_rich_presence::{ + activity::{Activity, Assets, Button}, + DiscordIpc, DiscordIpcClient, +}; + +use crate::framework::error::{GameError, GameResult}; +use crate::game::{player::Player, stage::StageData}; + +pub enum DiscordRPCState { + Initializing, + Idling, + InGame, + Jukebox, +} + +pub struct DiscordRPC { + pub enabled: bool, + pub ready: bool, + + client: DiscordIpcClient, + state: DiscordRPCState, + life: u16, + max_life: u16, + stage_name: String, +} + +impl DiscordRPC { + pub fn new(app_id: &str) -> Self { + Self { + enabled: false, + ready: false, + + client: DiscordIpcClient::new(app_id).unwrap(), + state: DiscordRPCState::Idling, + life: 0, + max_life: 0, + stage_name: String::new(), + } + } + + pub fn start(&mut self) -> GameResult { + log::info!("Starting Discord RPC client..."); + + match self.client.connect() { + Ok(_) => { + self.ready = true; + Ok(()) + } + Err(e) => Err(GameError::DiscordRPCError(e.to_string())), + } + } + + fn update(&mut self) -> GameResult { + if !self.enabled { + return Ok(()); + } + + let (state, details) = match self.state { + DiscordRPCState::Initializing => ("Initializing...".to_owned(), "Just started playing".to_owned()), + DiscordRPCState::Idling => ("In the menus".to_owned(), "Idling".to_owned()), + DiscordRPCState::InGame => { + (format!("Currently in: {}", self.stage_name), format!("HP: {} / {}", self.life, self.max_life)) + } + DiscordRPCState::Jukebox => ("In the menus".to_owned(), "Listening to the soundtrack".to_owned()), + }; + + log::debug!("Updating Discord RPC state: {} - {}", state, details); + + let activity = Activity::new() + .state(state.as_str()) + .details(details.as_str()) + .assets(Assets::new().large_image("drs")) + .buttons(vec![Button::new("doukutsu-rs on GitHub", "https://github.com/doukutsu-rs/doukutsu-rs")]); + + match self.client.set_activity(activity) { + Ok(_) => Ok(()), + Err(e) => Err(GameError::DiscordRPCError(e.to_string())), + } + } + + pub fn update_stage(&mut self, stage: &StageData) -> GameResult { + self.stage_name = stage.name.clone(); + self.update() + } + + pub fn update_hp(&mut self, player: &Player) -> GameResult { + self.life = player.life; + self.max_life = player.max_life; + self.update() + } + + pub fn set_initializing(&mut self) -> GameResult { + self.set_state(DiscordRPCState::Initializing) + } + + pub fn set_idling(&mut self) -> GameResult { + self.set_state(DiscordRPCState::Idling) + } + + pub fn set_in_game(&mut self) -> GameResult { + self.set_state(DiscordRPCState::InGame) + } + + pub fn set_in_jukebox(&mut self) -> GameResult { + self.set_state(DiscordRPCState::Jukebox) + } + + pub fn set_state(&mut self, state: DiscordRPCState) -> GameResult { + self.state = state; + self.update() + } + + pub fn clear(&mut self) -> GameResult { + let _ = self.client.clear_activity(); + Ok(()) + } + + pub fn dispose(&mut self) { + let _ = self.client.close(); + } +} diff --git a/src/framework/error.rs b/src/framework/error.rs index b1f27a2..27f4ed6 100644 --- a/src/framework/error.rs +++ b/src/framework/error.rs @@ -3,8 +3,8 @@ use std::error::Error; use std::fmt; use std::string::FromUtf8Error; -use std::sync::{Arc, PoisonError}; use std::sync::mpsc::SendError; +use std::sync::{Arc, PoisonError}; /// An enum containing all kinds of game framework errors. #[derive(Debug, Clone)] @@ -44,6 +44,8 @@ pub enum GameError { InvalidValue(String), /// Something went wrong while executing a debug command line command. CommandLineError(String), + /// Something went wrong while initializing or modifying Discord rich presence values. + DiscordRPCError(String), } impl fmt::Display for GameError { diff --git a/src/game/mod.rs b/src/game/mod.rs index d8f1db8..cf52f48 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -237,6 +237,12 @@ pub fn init(options: LaunchOptions) -> GameResult { game.state.get_mut().fs_container = Some(fs_container); + #[cfg(feature = "discord-rpc")] + if game.state.get_mut().settings.discord_rpc { + game.state.get_mut().discord_rpc.enabled = true; + game.state.get_mut().discord_rpc.start()?; + } + game.state.get_mut().next_scene = Some(Box::new(LoadingScene::new())); log::info!("Starting main loop..."); context.run(game.as_mut().get_mut())?; diff --git a/src/game/player/mod.rs b/src/game/player/mod.rs index 218e8a2..da6ddb0 100644 --- a/src/game/player/mod.rs +++ b/src/game/player/mod.rs @@ -923,6 +923,9 @@ impl Player { let _ = npc_list.spawn(0x100, npc.clone()); } } + + #[cfg(feature = "discord-rpc")] + let _ = state.discord_rpc.update_hp(&self); } pub fn update_teleport_counter(&mut self, state: &SharedGameState) { diff --git a/src/game/player/player_hit.rs b/src/game/player/player_hit.rs index 84df9e6..11d6f7b 100644 --- a/src/game/player/player_hit.rs +++ b/src/game/player/player_hit.rs @@ -319,6 +319,9 @@ impl Player { npc.cond.set_alive(false); state.sound_manager.play_sfx(20); + + #[cfg(feature = "discord-rpc")] + let _ = state.discord_rpc.update_hp(&self); } _ => {} } diff --git a/src/game/scripting/tsc/text_script.rs b/src/game/scripting/tsc/text_script.rs index 3464cbb..0893a57 100644 --- a/src/game/scripting/tsc/text_script.rs +++ b/src/game/scripting/tsc/text_script.rs @@ -11,8 +11,8 @@ use std::rc::Rc; use num_traits::{clamp, FromPrimitive}; use crate::bitfield; -use crate::common::{Direction, FadeDirection, FadeState, Rect}; use crate::common::Direction::{Left, Right}; +use crate::common::{Direction, FadeDirection, FadeState, Rect}; use crate::engine_constants::EngineConstants; use crate::entity::GameEntity; use crate::framework::context::Context; @@ -1032,6 +1032,9 @@ impl TextScriptVM { game_scene.player2.life += life; game_scene.player2.max_life += life; + #[cfg(feature = "discord-rpc")] + state.discord_rpc.update_hp(&game_scene.player1)?; + exec_state = TextScriptExecutionState::Running(event, cursor.position() as u32); } TSCOpCode::FAC => { @@ -1534,6 +1537,9 @@ impl TextScriptVM { game_scene.player1.life = clamp(game_scene.player1.life + life, 0, game_scene.player1.max_life); game_scene.player2.life = clamp(game_scene.player2.life + life, 0, game_scene.player2.max_life); + #[cfg(feature = "discord-rpc")] + state.discord_rpc.update_hp(&game_scene.player1)?; + exec_state = TextScriptExecutionState::Running(event, cursor.position() as u32); } TSCOpCode::ITp => { diff --git a/src/game/settings.rs b/src/game/settings.rs index 044f838..977695a 100644 --- a/src/game/settings.rs +++ b/src/game/settings.rs @@ -81,6 +81,8 @@ pub struct Settings { pub more_rust: bool, #[serde(default = "default_cutscene_skip_mode")] pub cutscene_skip_mode: CutsceneSkipMode, + #[serde(default = "default_true")] + pub discord_rpc: bool, } fn default_true() -> bool { @@ -89,7 +91,7 @@ fn default_true() -> bool { #[inline(always)] fn current_version() -> u32 { - 21 + 22 } #[inline(always)] @@ -327,6 +329,11 @@ impl Settings { }; } + if self.version == 21 { + self.version = 22; + self.discord_rpc = true; + } + if self.version != initial_version { log::info!("Upgraded configuration file from version {} to {}.", initial_version, self.version); } @@ -432,6 +439,7 @@ impl Default for Settings { noclip: false, more_rust: false, cutscene_skip_mode: CutsceneSkipMode::Hold, + discord_rpc: true, } } } diff --git a/src/game/shared_game_state.rs b/src/game/shared_game_state.rs index c4b6cd5..a2c66d8 100644 --- a/src/game/shared_game_state.rs +++ b/src/game/shared_game_state.rs @@ -5,6 +5,8 @@ use chrono::{Datelike, Local}; use crate::common::{ControlFlags, Direction, FadeState}; use crate::components::draw_common::{draw_number, Alignment}; use crate::data::vanilla::VanillaExtractor; +#[cfg(feature = "discord-rpc")] +use crate::discord::DiscordRPC; use crate::engine_constants::EngineConstants; use crate::framework::backend::BackendTexture; use crate::framework::context::Context; @@ -334,6 +336,8 @@ pub struct SharedGameState { pub loc: Locale, pub tutorial_counter: u16, pub more_rust: bool, + #[cfg(feature = "discord-rpc")] + pub discord_rpc: DiscordRPC, pub shutdown: bool, } @@ -434,6 +438,11 @@ impl SharedGameState { let more_rust = (current_time.month() == 7 && current_time.day() == 7) || settings.more_rust; let seed = chrono::Local::now().timestamp() as i32; + let discord_rpc_app_id = match option_env!("DISCORD_RPC_APP_ID") { + Some(app_id) => app_id, + None => "1076523467337367622", + }; + Ok(SharedGameState { control_flags: ControlFlags(0), game_flags: BitVec::with_size(8000), @@ -489,6 +498,8 @@ impl SharedGameState { loc: locale, tutorial_counter: 0, more_rust, + #[cfg(feature = "discord-rpc")] + discord_rpc: DiscordRPC::new(discord_rpc_app_id), shutdown: false, }) } @@ -709,6 +720,9 @@ impl SharedGameState { pub fn shutdown(&mut self) { self.shutdown = true; + + #[cfg(feature = "discord-rpc")] + self.discord_rpc.dispose(); } // Stops SFX 40/41/58 (CPS and CSS) diff --git a/src/lib.rs b/src/lib.rs index 0d11d29..cfb38ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,8 @@ extern crate strum_macros; mod common; mod components; mod data; +#[cfg(feature = "discord-rpc")] +pub mod discord; #[cfg(feature = "editor")] mod editor; mod engine_constants; diff --git a/src/live_debugger/command_line.rs b/src/live_debugger/command_line.rs index 9468f37..a208957 100644 --- a/src/live_debugger/command_line.rs +++ b/src/live_debugger/command_line.rs @@ -1,11 +1,11 @@ use num_traits::FromPrimitive; use crate::framework::error::{GameError::CommandLineError, GameResult}; -use crate::game::shared_game_state::SharedGameState; use crate::game::npc::NPC; -use crate::scene::game_scene::GameScene; use crate::game::scripting::tsc::text_script::{ScriptMode, TextScript, TextScriptEncoding}; +use crate::game::shared_game_state::SharedGameState; use crate::game::weapon::WeaponType; +use crate::scene::game_scene::GameScene; #[derive(Clone)] pub enum CommandLineCommand { @@ -230,6 +230,9 @@ impl CommandLineCommand { CommandLineCommand::SetMaxHP(hp_count) => { game_scene.player1.max_life = hp_count; game_scene.player1.life = hp_count; + + #[cfg(feature = "discord-rpc")] + state.discord_rpc.update_hp(&game_scene.player1)?; } CommandLineCommand::SpawnNPC(id) => { let mut npc = NPC::create(id, &state.npc_table); diff --git a/src/live_debugger/mod.rs b/src/live_debugger/mod.rs index b6c7595..95e79bb 100644 --- a/src/live_debugger/mod.rs +++ b/src/live_debugger/mod.rs @@ -268,6 +268,9 @@ impl LiveDebugger { if scene.player1.life == 0 { scene.player1.life = scene.player1.max_life; + + #[cfg(feature = "discord-rpc")] + let _ = state.discord_rpc.update_hp(&scene.player1); } scene.player2 = game_scene.player2.clone(); diff --git a/src/menu/settings_menu.rs b/src/menu/settings_menu.rs index 36b3e3d..a0cac51 100644 --- a/src/menu/settings_menu.rs +++ b/src/menu/settings_menu.rs @@ -114,6 +114,8 @@ enum BehaviorMenuEntry { GameTiming, PauseOnFocusLoss, CutsceneSkipMode, + #[cfg(feature = "discord-rpc")] + DiscordRPC, Back, } @@ -500,6 +502,15 @@ impl SettingsMenu { ), ); + #[cfg(feature = "discord-rpc")] + self.behavior.push_entry( + BehaviorMenuEntry::DiscordRPC, + MenuEntry::Toggle( + state.loc.t("menus.options_menu.behavior_menu.discord_rpc").to_owned(), + state.settings.discord_rpc, + ), + ); + self.behavior.push_entry(BehaviorMenuEntry::Back, MenuEntry::Active(state.loc.t("common.back").to_owned())); self.links.push_entry(LinksMenuEntry::Back, MenuEntry::Active(state.loc.t("common.back").to_owned())); @@ -899,6 +910,26 @@ impl SettingsMenu { let _ = state.settings.save(ctx); } } + #[cfg(feature = "discord-rpc")] + MenuSelectionResult::Selected(BehaviorMenuEntry::DiscordRPC, toggle) => { + if let MenuEntry::Toggle(_, value) = toggle { + state.settings.discord_rpc = !state.settings.discord_rpc; + let _ = state.settings.save(ctx); + + *value = state.settings.discord_rpc; + state.discord_rpc.enabled = state.settings.discord_rpc; + + if state.discord_rpc.enabled { + if !state.discord_rpc.ready { + state.discord_rpc.start()?; + } + + state.discord_rpc.set_idling()?; + } else { + state.discord_rpc.clear()?; + } + } + } MenuSelectionResult::Selected(BehaviorMenuEntry::Back, _) | MenuSelectionResult::Canceled => { self.current = CurrentMenu::MainMenu; } diff --git a/src/scene/game_scene.rs b/src/scene/game_scene.rs index 4138de4..8c8502a 100644 --- a/src/scene/game_scene.rs +++ b/src/scene/game_scene.rs @@ -1739,6 +1739,17 @@ impl Scene for GameScene { self.pause_menu.init(state, ctx)?; self.whimsical_star.init(&self.player1); + #[cfg(feature = "discord-rpc")] + { + if self.stage.data.map == state.stages[state.constants.game.intro_stage as usize].map { + state.discord_rpc.set_initializing()?; + } else { + state.discord_rpc.update_hp(&self.player1)?; + state.discord_rpc.update_stage(&self.stage.data)?; + state.discord_rpc.set_in_game()?; + } + } + Ok(()) } diff --git a/src/scene/jukebox_scene.rs b/src/scene/jukebox_scene.rs index 5de0ebc..779d7af 100644 --- a/src/scene/jukebox_scene.rs +++ b/src/scene/jukebox_scene.rs @@ -104,6 +104,9 @@ impl Scene for JukeboxScene { self.previous_pause_on_focus_loss_setting = state.settings.pause_on_focus_loss; state.settings.pause_on_focus_loss = false; + #[cfg(feature = "discord-rpc")] + state.discord_rpc.set_in_jukebox()?; + Ok(()) } diff --git a/src/scene/title_scene.rs b/src/scene/title_scene.rs index b65c03a..8cd3562 100644 --- a/src/scene/title_scene.rs +++ b/src/scene/title_scene.rs @@ -277,6 +277,9 @@ impl Scene for TitleScene { state.replay_state = ReplayState::None; state.textscript_vm.flags.set_cutscene_skip(false); + #[cfg(feature = "discord-rpc")] + state.discord_rpc.set_idling()?; + Ok(()) }