diff --git a/.github/workflows/Linux_x86_64_SDL1.yml b/.github/workflows/Linux_x86_64_SDL1.yml index 4503a78f3..bf56d1966 100644 --- a/.github/workflows/Linux_x86_64_SDL1.yml +++ b/.github/workflows/Linux_x86_64_SDL1.yml @@ -31,7 +31,7 @@ jobs: - name: Configure CMake shell: bash working-directory: ${{github.workspace}} - run: cmake -S. -Bbuild .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCPACK=ON -DUSE_SDL1=ON + run: cmake -S. -Bbuild .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCPACK=ON -DUSE_SDL1=ON -DDISCORD_INTEGRATION=ON - name: Build working-directory: ${{github.workspace}} diff --git a/.github/workflows/MacOSX.yml b/.github/workflows/MacOSX.yml index 623360b5b..42525d8f0 100644 --- a/.github/workflows/MacOSX.yml +++ b/.github/workflows/MacOSX.yml @@ -39,7 +39,7 @@ jobs: # access regardless of the host operating system shell: bash working-directory: ${{github.workspace}} - run: cmake -S. -Bbuild -DMACOSX_STANDALONE_APP_BUNDLE=ON + run: cmake -S. -Bbuild -DMACOSX_STANDALONE_APP_BUNDLE=ON -DDISCORD_INTEGRATION=ON - name: Build working-directory: ${{github.workspace}} diff --git a/.github/workflows/Windows_x64.yml b/.github/workflows/Windows_x64.yml index 8eda675d1..72fb3a7b3 100644 --- a/.github/workflows/Windows_x64.yml +++ b/.github/workflows/Windows_x64.yml @@ -26,7 +26,7 @@ jobs: - name: Configure CMake shell: bash working-directory: ${{github.workspace}} - run: cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCPACK=ON -DCMAKE_TOOLCHAIN_FILE=../CMake/platforms/mingwcc64.toolchain.cmake -DDEVILUTIONX_SYSTEM_BZIP2=OFF -DDEVILUTIONX_STATIC_LIBSODIUM=ON + run: cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCPACK=ON -DCMAKE_TOOLCHAIN_FILE=../CMake/platforms/mingwcc64.toolchain.cmake -DDEVILUTIONX_SYSTEM_BZIP2=OFF -DDEVILUTIONX_STATIC_LIBSODIUM=ON -DDISCORD_INTEGRATION=ON - name: Build working-directory: ${{github.workspace}} diff --git a/.github/workflows/Windows_x86.yml b/.github/workflows/Windows_x86.yml index c1c2b0d7d..fe9891657 100644 --- a/.github/workflows/Windows_x86.yml +++ b/.github/workflows/Windows_x86.yml @@ -26,7 +26,7 @@ jobs: - name: Configure CMake shell: bash working-directory: ${{github.workspace}} - run: cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCPACK=ON -DCMAKE_TOOLCHAIN_FILE=../CMake/platforms/mingwcc.toolchain.cmake -DDEVILUTIONX_SYSTEM_BZIP2=OFF -DDEVILUTIONX_STATIC_LIBSODIUM=ON + run: cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCPACK=ON -DCMAKE_TOOLCHAIN_FILE=../CMake/platforms/mingwcc.toolchain.cmake -DDEVILUTIONX_SYSTEM_BZIP2=OFF -DDEVILUTIONX_STATIC_LIBSODIUM=ON -DDISCORD_INTEGRATION=ON - name: Build working-directory: ${{github.workspace}} diff --git a/3rdParty/discord/CMakeLists.txt b/3rdParty/discord/CMakeLists.txt new file mode 100644 index 000000000..b1327d7d4 --- /dev/null +++ b/3rdParty/discord/CMakeLists.txt @@ -0,0 +1,36 @@ +include(functions/FetchContent_MakeAvailableExcludeFromAll) +include(FetchContent) + +FetchContent_Declare(discordsrc + URL https://dl-game-sdk.discordapp.net/2.5.6/discord_game_sdk.zip + URL_HASH MD5=f86f15957cc9fbf04e3db10462027d58 +) +FetchContent_MakeAvailableExcludeFromAll(discordsrc) + +file(GLOB discord_SRCS ${discordsrc_SOURCE_DIR}/cpp/*.cpp) +add_library(discord STATIC ${discord_SRCS}) +target_include_directories(discord INTERFACE "${discordsrc_SOURCE_DIR}/..") + +if (CMAKE_SIZEOF_VOID_P EQUAL 4) + set(DISCORD_LIB_DIR "${discordsrc_SOURCE_DIR}/lib/x86") +else() + set(DISCORD_LIB_DIR "${discordsrc_SOURCE_DIR}/lib/x86_64") +endif() +set(DISCORD_SHARED_LIB "${DISCORD_LIB_DIR}/discord_game_sdk${CMAKE_SHARED_LIBRARY_SUFFIX}") + +if(WIN32 AND NOT MSVC) + add_library(discord_game_sdk SHARED "discord_game_sdk_fake.cpp") + set_target_properties(discord_game_sdk PROPERTIES PREFIX "") +else() + find_library(DISCORD_LIB discord_game_sdk${CMAKE_SHARED_LIBRARY_SUFFIX} ${DISCORD_LIB_DIR}) + + add_library(discord_game_sdk SHARED IMPORTED GLOBAL) + set_property(TARGET discord_game_sdk PROPERTY IMPORTED_IMPLIB ${DISCORD_LIB}) + set_property(TARGET discord_game_sdk PROPERTY IMPORTED_LOCATION ${DISCORD_SHARED_LIB}) +endif() + +file(COPY ${DISCORD_SHARED_LIB} DESTINATION "${CMAKE_BINARY_DIR}") + +if(CPACK) + install(FILES ${DISCORD_SHARED_LIB} DESTINATION ".") +endif() diff --git a/3rdParty/discord/discord_game_sdk_fake.cpp b/3rdParty/discord/discord_game_sdk_fake.cpp new file mode 100644 index 000000000..f496b480b --- /dev/null +++ b/3rdParty/discord/discord_game_sdk_fake.cpp @@ -0,0 +1,4 @@ +extern "C" { +__declspec(dllexport) int DiscordCreate(int, void *, void *) { return 0; } +__declspec(dllexport) int DiscordVersion(int, void *) { return 0; } +} diff --git a/CMake/Dependencies.cmake b/CMake/Dependencies.cmake index 2534a705b..0a0273b4b 100644 --- a/CMake/Dependencies.cmake +++ b/CMake/Dependencies.cmake @@ -131,3 +131,7 @@ endif() if(NOT NONET AND NOT DISABLE_ZERO_TIER) add_subdirectory(3rdParty/libzt) endif() + +if(DISCORD_INTEGRATION) + add_subdirectory(3rdParty/discord) +endif() diff --git a/CMakeLists.txt b/CMakeLists.txt index 6768cdc1b..ca9f480b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ cmake_dependent_option(PACKET_ENCRYPTION "Encrypt network packets" ON "NOT NONET option(NOSOUND "Disable sound support" OFF) option(RUN_TESTS "Build and run tests" OFF) option(ENABLE_CODECOVERAGE "Instrument code for code coverage (only enabled with RUN_TESTS)" OFF) +option(DISCORD_INTEGRATION "Build with Discord SDK for rich presence support" OFF) option(DISABLE_STREAMING_MUSIC "Disable streaming music (to work around broken platform implementations)" OFF) mark_as_advanced(DISABLE_STREAMING_MUSIC) @@ -408,6 +409,12 @@ if(NINTENDO_3DS) endif() endif() +if(DISCORD_INTEGRATION) + list(APPEND libdevilutionx_SRCS + Source/discord/discord.cpp + ) +endif() + if(RUN_TESTS) set(devilutionxtest_SRCS test/appfat_test.cpp @@ -493,6 +500,11 @@ if(RUN_TESTS) gtest_add_tests(devilutionx-tests "" AUTO) endif() +if(DISCORD_INTEGRATION) + target_compile_definitions(libdevilutionx PRIVATE DISCORD) + target_link_libraries(libdevilutionx PRIVATE discord discord_game_sdk) +endif() + if(GPERF) find_package(Gperftools REQUIRED) endif() diff --git a/CMakeSettings.json b/CMakeSettings.json index 7561156fb..4df4c2be5 100644 --- a/CMakeSettings.json +++ b/CMakeSettings.json @@ -8,7 +8,14 @@ "installRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\install\\${name}", "inheritEnvironments": [ "msvc_x64" ], "intelliSenseMode": "windows-msvc-x64", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x64-Debug-UnitTests", @@ -19,7 +26,14 @@ "inheritEnvironments": [ "msvc_x64" ], "intelliSenseMode": "windows-msvc-x64", "cmakeCommandArgs": "-DRUN_TESTS=ON", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x64-Debug-SDL1", @@ -30,7 +44,14 @@ "inheritEnvironments": [ "msvc_x64" ], "intelliSenseMode": "windows-msvc-x64", "cmakeCommandArgs": "-DUSE_SDL1=ON", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x64-Release", @@ -41,7 +62,14 @@ "cmakeCommandArgs": "-DCPACK=ON", "inheritEnvironments": [ "msvc_x64" ], "intelliSenseMode": "windows-msvc-x64", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x86-Debug", @@ -51,7 +79,14 @@ "installRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\install\\${name}", "inheritEnvironments": [ "msvc_x86" ], "intelliSenseMode": "windows-msvc-x86", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x86-Debug-UnitTests", @@ -62,7 +97,14 @@ "inheritEnvironments": [ "msvc_x86" ], "intelliSenseMode": "windows-msvc-x86", "cmakeCommandArgs": "-DRUN_TESTS=ON", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x86-Release", @@ -73,7 +115,14 @@ "cmakeCommandArgs": "-DCPACK=ON", "inheritEnvironments": [ "msvc_x86" ], "intelliSenseMode": "windows-msvc-x86", - "enableClangTidyCodeAnalysis": true + "enableClangTidyCodeAnalysis": true, + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] }, { "name": "x64-Debug-WSL-GCC", @@ -87,7 +136,13 @@ "ctestCommandArgs": "", "inheritEnvironments": [ "linux_x64" ], "wslPath": "${defaultWSLPath}", - "variables": [] + "variables": [ + { + "name": "DISCORD_INTEGRATION", + "value": "True", + "type": "BOOL" + } + ] } ] } diff --git a/Source/DiabloUI/diabloui.cpp b/Source/DiabloUI/diabloui.cpp index cd724b066..eaaec4f27 100644 --- a/Source/DiabloUI/diabloui.cpp +++ b/Source/DiabloUI/diabloui.cpp @@ -14,6 +14,7 @@ #include "DiabloUI/scrollbar.h" #include "controls/controller.h" #include "controls/menu_controls.h" +#include "discord/discord.h" #include "dx.h" #include "hwcursor.hpp" #include "palette.h" @@ -760,6 +761,8 @@ void UiPollAndRender() // so defer until after render and fade-in ctr_vkbdFlush(); #endif + + discord_manager::UpdateMenu(); } namespace { diff --git a/Source/DiabloUI/title.cpp b/Source/DiabloUI/title.cpp index 7042868fd..1c306b7f9 100644 --- a/Source/DiabloUI/title.cpp +++ b/Source/DiabloUI/title.cpp @@ -1,6 +1,7 @@ #include "DiabloUI/diabloui.h" #include "control.h" #include "controls/menu_controls.h" +#include "discord/discord.h" #include "utils/language.h" namespace devilution { @@ -53,6 +54,8 @@ void UiTitleDialog() UiRenderItems(vecTitleScreen); UiFadeIn(); + discord_manager::UpdateMenu(); + while (SDL_PollEvent(&event) != 0) { if (GetMenuAction(event) != MenuAction_NONE) { endMenu = true; diff --git a/Source/diablo.cpp b/Source/diablo.cpp index ef54b0e8d..de6406064 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -23,6 +23,7 @@ #include "controls/touch/gamepad.h" #include "controls/touch/renderers.h" #include "diablo.h" +#include "discord/discord.h" #include "doom.h" #include "drlg_l1.h" #include "drlg_l2.h" @@ -705,6 +706,7 @@ void RunGameLoop(interface_mode uMsg) gbGameLoopStartup = true; nthread_ignore_mutex(false); + discord_manager::StartGame(); #ifdef GPERF_HEAP_FIRST_GAME_ITERATION unsigned run_game_iteration = 0; #endif @@ -737,6 +739,9 @@ void RunGameLoop(interface_mode uMsg) bool runGameLoop = demo::IsRunning() ? demo::GetRunGameLoop(drawGame, processInput) : nthread_has_500ms_passed(); if (demo::IsRecording()) demo::RecordGameLoopResult(runGameLoop); + + discord_manager::UpdateGame(); + if (!runGameLoop) { if (processInput) ProcessInput(); diff --git a/Source/discord/discord.cpp b/Source/discord/discord.cpp new file mode 100644 index 000000000..9a1fc90e2 --- /dev/null +++ b/Source/discord/discord.cpp @@ -0,0 +1,193 @@ +#include "discord.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "config.h" +#include "gendung.h" +#include "init.h" +#include "multi.h" +#include "panels/charpanel.hpp" +#include "player.h" +#include "setmaps.h" +#include "utils/language.h" + +namespace devilution { +namespace discord_manager { + +// App ID used for DevilutionX's Diablo (classic Diablo's is 496571953147150354) +constexpr discord::ClientId DiscordDevilutionxAppId = 795760213524742205; + +constexpr auto IgnoreResult = [](discord::Result result) {}; + +discord::Core *discord_core = []() -> discord::Core * { + discord::Core *core; + discord::Result result = discord::Core::Create(DiscordDevilutionxAppId, DiscordCreateFlags_NoRequireDiscord, &core); + if (result != discord::Result::Ok) { + core = nullptr; + } + return core; +}(); + +struct PlayerData { + dungeon_type dungeonArea; + _setlevels questMap; + Uint8 dungeonLevel; + Sint8 playerLevel; + int playerGfx; + + // Why??? This is POD + bool operator!=(const PlayerData &other) const + { + return std::tie(dungeonArea, dungeonLevel, playerLevel, playerGfx) != std::tie(other.dungeonArea, other.dungeonLevel, other.playerLevel, other.playerGfx); + } +}; + +bool want_menu_update = true; +PlayerData tracked_data; +Sint64 start_time = 0; + +std::string GetLocationString() +{ + // Quest Level Name + if (setlevel) { + return _(QuestLevelNames[setlvlnum]); + } + + // Dungeon Name + constexpr std::array DungeonStrs = { N_("Town"), N_("Cathedral"), N_("Catacombs"), N_("Caves"), N_("Hell"), N_("Nest"), N_("Crypt") }; + const char *dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + if (tracked_data.dungeonArea != DTYPE_NONE) { + dungeonStr = _(DungeonStrs[tracked_data.dungeonArea]); + } + + // Dungeon Level + if (tracked_data.dungeonLevel > 0) { + int level = tracked_data.dungeonLevel; + if (tracked_data.dungeonArea == DTYPE_NEST) + level -= 16; + else if (tracked_data.dungeonArea == DTYPE_CRYPT) + level -= 20; + + return fmt::format(_(/* TRANSLATORS: dungeon type and floor number i.e. "Cathedral 3"*/ "{} {}"), dungeonStr, level); + } + return dungeonStr; +} + +std::string GetCharacterString() +{ + const char *charClassStr = ClassStrTbl[static_cast(Players[MyPlayerId]._pClass)]; + return fmt::format(_(/* TRANSLATORS: Discord character, i.e. "Lv 6 Warrior" */ "Lv {} {}"), tracked_data.playerLevel, charClassStr); +} + +std::string GetDetailString() +{ + return fmt::format("{} - {}", GetCharacterString(), GetLocationString()); +} + +std::string GetStateString() +{ + constexpr std::array DifficultyStrs = { N_("Normal"), N_("Nightmare"), N_("Hell") }; + const char *difficultyStr = _(DifficultyStrs[sgGameInitInfo.nDifficulty]); + return fmt::format(_(/* TRANSLATORS: Discord state i.e. "Nightmare difficulty" */ "{} difficulty"), difficultyStr); +} + +std::string GetTooltipString() +{ + return fmt::format("{} - {}", Players[MyPlayerId]._pName, GetCharacterString()); +} + +std::string GetPlayerAssetString() +{ + char heroChar = CharChar[static_cast(Players[MyPlayerId]._pClass)]; + char armourChar = ArmourChar[tracked_data.playerGfx >> 4]; + char wpnChar = WepChar[tracked_data.playerGfx & 0xF]; + + std::string result = fmt::format("{}{}{}as", heroChar, armourChar, wpnChar); + std::transform(std::begin(result), std::end(result), std::begin(result), [](char c) { return static_cast(std::tolower(c)); }); + return result; +} + +void ResetStartTime() +{ + start_time = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +const char *GetIconAsset() +{ + return gbIsHellfire ? "hellfire" : "icon"; +} + +void UpdateGame() +{ + if (discord_core == nullptr) + return; + + auto newData = PlayerData { + leveltype, setlvlnum, currlevel, Players[MyPlayerId]._pLevel, Players[MyPlayerId]._pgfxnum + }; + if (newData != tracked_data) { + tracked_data = newData; + + // Update status strings + discord::Activity activity = {}; + activity.SetName(PROJECT_NAME); + activity.SetState(GetStateString().c_str()); + activity.SetDetails(GetDetailString().c_str()); + activity.SetInstance(true); + + activity.GetTimestamps().SetStart(start_time); + + // Set image assets + activity.GetAssets().SetLargeImage(GetPlayerAssetString().c_str()); + activity.GetAssets().SetLargeText(GetTooltipString().c_str()); + activity.GetAssets().SetSmallImage(GetIconAsset()); + activity.GetAssets().SetSmallText(gszProductName); + + discord_core->ActivityManager().UpdateActivity(activity, IgnoreResult); + } + discord_core->RunCallbacks(); +} + +void StartGame() +{ + tracked_data = PlayerData { dungeon_type::DTYPE_NONE, _setlevels::SL_NONE, 0, 0, 0 }; + want_menu_update = true; + ResetStartTime(); +} + +void UpdateMenu(bool forced) +{ + if (discord_core == nullptr) + return; + + if (want_menu_update || forced) { + if (!forced) { + ResetStartTime(); + } + want_menu_update = false; + + discord::Activity activity = {}; + activity.SetName(PROJECT_NAME); + activity.SetState(_(/* TRANSLATORS: Discord activity, not in game */ "In Menu")); + + activity.GetTimestamps().SetStart(start_time); + + activity.GetAssets().SetLargeImage(GetIconAsset()); + activity.GetAssets().SetLargeText(gszProductName); + + discord_core->ActivityManager().UpdateActivity(activity, IgnoreResult); + } + discord_core->RunCallbacks(); +} + +} // namespace discord_manager +} // namespace devilution diff --git a/Source/discord/discord.h b/Source/discord/discord.h new file mode 100644 index 000000000..636391e20 --- /dev/null +++ b/Source/discord/discord.h @@ -0,0 +1,25 @@ +#pragma once + +namespace devilution { +namespace discord_manager { + +#ifdef DISCORD +void UpdateGame(); +void StartGame(); +void UpdateMenu(bool forced = false); +#else +constexpr void UpdateGame() +{ +} + +constexpr void StartGame() +{ +} + +constexpr void UpdateMenu(bool forced = false) +{ +} +#endif + +} // namespace discord_manager +} // namespace devilution diff --git a/Source/options.cpp b/Source/options.cpp index 28522044d..ab7516940 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -33,6 +33,7 @@ #include #include "diablo.h" +#include "discord/discord.h" #include "engine/demomode.h" #include "options.h" #include "qol/monhealthbar.h" @@ -278,6 +279,7 @@ void OptionLanguageCodeChanged() void OptionGameModeChanged() { gbIsHellfire = *sgOptions.StartUp.gameMode == StartUpGameMode::Hellfire; + discord_manager::UpdateMenu(true); } void OptionSharewareChanged() diff --git a/Source/player.cpp b/Source/player.cpp index d9a1e0c87..94dd41605 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -162,37 +162,6 @@ struct DirectionSettings { void (*walkModeHandler)(int, const DirectionSettings &); }; -/** Maps from armor animation to letter used in graphic files. */ -const char ArmourChar[4] = { - 'L', // light - 'M', // medium - 'H', // heavy - 0 -}; -/** Maps from weapon animation to letter used in graphic files. */ -const char WepChar[10] = { - 'N', // unarmed - 'U', // no weapon + shield - 'S', // sword + no shield - 'D', // sword + shield - 'B', // bow - 'A', // axe - 'M', // blunt + no shield - 'H', // blunt + shield - 'T', // staff - 0 -}; -/** Maps from player class to letter used in graphic files. */ -const char CharChar[] = { - 'W', // warrior - 'R', // rogue - 'S', // sorcerer - 'M', // monk - 'B', - 'C', - 0 -}; - /** Specifies the frame of each animation for which an action is triggered, for each player class. */ const int PlrGFXAnimLens[enum_size::value][11] = { { 10, 16, 8, 2, 20, 20, 6, 20, 8, 9, 14 }, diff --git a/Source/player.h b/Source/player.h index d47bd02cf..93e98f23a 100644 --- a/Source/player.h +++ b/Source/player.h @@ -146,6 +146,35 @@ enum action_id : int8_t { // clang-format on }; +/** Maps from armor animation to letter used in graphic files. */ +constexpr std::array ArmourChar = { + 'L', // light + 'M', // medium + 'H', // heavy +}; +/** Maps from weapon animation to letter used in graphic files. */ +constexpr std::array WepChar = { + 'N', // unarmed + 'U', // no weapon + shield + 'S', // sword + no shield + 'D', // sword + shield + 'B', // bow + 'A', // axe + 'M', // blunt + no shield + 'H', // blunt + shield + 'T', // staff +}; + +/** Maps from player class to letter used in graphic files. */ +constexpr std::array CharChar = { + 'W', // warrior + 'R', // rogue + 'S', // sorcerer + 'M', // monk + 'B', + 'C', +}; + /** * @brief Contains Data (CelSprites) for a player graphic (player_graphic) */