diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f6e3027b..90a0a0b3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ set(Impacto_Src src/inputsystem.cpp src/voicetable.cpp src/animation.cpp + src/externalfont.cpp src/text/backlogpage.cpp src/text/text.cpp @@ -94,6 +95,7 @@ set(Impacto_Src src/profile/hud/loadingdisplay.cpp src/profile/hud/datedisplay.cpp src/profile/hud/tipsnotification.cpp + src/profile/hud/achievementnotification.cpp src/profile/ui/commonmenu.cpp src/profile/ui/systemmenu.cpp @@ -256,6 +258,7 @@ set(Impacto_Src src/hud/skipicondisplay.cpp src/hud/waiticondisplay.cpp src/hud/tipsnotification.cpp + src/hud/achievementnotification.cpp src/hud/cc/dialoguebox.cpp src/hud/cc/nametagdisplay.cpp @@ -465,6 +468,7 @@ set(Impacto_Header src/profile/hud/loadingdisplay.h src/profile/hud/datedisplay.h src/profile/hud/tipsnotification.h + src/profile/hud/achievementnotification.h src/profile/ui/commonmenu.h src/profile/ui/systemmenu.h @@ -619,6 +623,7 @@ set(Impacto_Header src/hud/skipicondisplay.h src/hud/waiticondisplay.h src/hud/tipsnotification.h + src/hud/achievementnotification.h src/hud/cc/dialoguebox.h src/hud/cc/nametagdisplay.h @@ -1168,9 +1173,25 @@ list(APPEND Impacto_Include_Dirs ${WebP_INCLUDE_DIRS}) if (VCPKG_TOOLCHAIN) find_package(fmt CONFIG REQUIRED) + find_package(Freetype REQUIRED) + find_package(harfbuzz CONFIG REQUIRED) endif () list(APPEND Impacto_Libs fmt::fmt) +if (VCPKG_TOOLCHAIN) + list(APPEND Impacto_Libs + Freetype::Freetype + harfbuzz::harfbuzz + ) +else () + pkg_check_modules(freetype2 REQUIRED IMPORTED_TARGET freetype2) + pkg_check_modules(harfbuzz REQUIRED IMPORTED_TARGET harfbuzz) + list(APPEND Impacto_Libs + PkgConfig::freetype2 + PkgConfig::harfbuzz + ) +endif () + list(APPEND Impacto_Libs pugixml::pugixml atrac9 diff --git a/games/common/NotoSansCJKjp-Regular.otf b/games/common/NotoSansCJKjp-Regular.otf new file mode 100644 index 000000000..f56224957 Binary files /dev/null and b/games/common/NotoSansCJKjp-Regular.otf differ diff --git a/games/common/achievementnotification.png b/games/common/achievementnotification.png new file mode 100644 index 000000000..9db0898fb Binary files /dev/null and b/games/common/achievementnotification.png differ diff --git a/profiles/cc/game.lua b/profiles/cc/game.lua index f3dee8eeb..52b19b50b 100644 --- a/profiles/cc/game.lua +++ b/profiles/cc/game.lua @@ -48,6 +48,7 @@ include(root.BasePaths.RootProfilesDir .. '/cc/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/cc/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/cc/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/cc/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/cc/font.lua'); include(root.BasePaths.RootProfilesDir .. '/cc/dialogue.lua'); diff --git a/profiles/cclcc/game.lua b/profiles/cclcc/game.lua index 99f4622c1..19ef03aa4 100644 --- a/profiles/cclcc/game.lua +++ b/profiles/cclcc/game.lua @@ -2,7 +2,8 @@ root.ActiveRenderer = RendererType.OpenGL; root.LayerCount = 100; root.GameFeatures = GameFeature.Sc3VirtualMachine | GameFeature.Renderer2D | GameFeature.Input | GameFeature.Audio | - GameFeature.Video | GameFeature.Subtitles | GameFeature.DebugMenu; + GameFeature.Video | GameFeature.Subtitles | GameFeature.DebugMenu | + GameFeature.Achievements; root.DesignWidth = 1920; root.DesignHeight = 1080; @@ -44,6 +45,7 @@ include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); include(root.BasePaths.RootProfilesDir .. '/common/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/common/scriptinput.lua'); include(root.BasePaths.RootProfilesDir .. '/common/scriptvars.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/cclcc/scriptinput.lua'); include(root.BasePaths.RootProfilesDir .. '/cclcc/scriptvars.lua'); diff --git a/profiles/chlcc/game.lua b/profiles/chlcc/game.lua index 42f22e39b..a9583c4fe 100644 --- a/profiles/chlcc/game.lua +++ b/profiles/chlcc/game.lua @@ -2,7 +2,8 @@ root.ActiveRenderer = RendererType.OpenGL; root.LayerCount = 100; root.GameFeatures = GameFeature.Sc3VirtualMachine | GameFeature.Renderer2D | GameFeature.Input | - GameFeature.Audio | GameFeature.Video | GameFeature.Subtitles | GameFeature.DebugMenu; + GameFeature.Audio | GameFeature.Video | GameFeature.Subtitles | GameFeature.DebugMenu | + GameFeature.Achievements; root.DesignWidth = 1280; root.DesignHeight = 720; @@ -45,6 +46,7 @@ include(root.BasePaths.RootProfilesDir .. '/chlcc/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/chlcc/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); include(root.BasePaths.RootProfilesDir .. '/common/charset.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/chlcc/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/chlcc/font.lua'); include(root.BasePaths.RootProfilesDir .. '/chlcc/dialogue.lua'); diff --git a/profiles/chn/game.lua b/profiles/chn/game.lua index 4d1125b89..463485aa6 100644 --- a/profiles/chn/game.lua +++ b/profiles/chn/game.lua @@ -43,6 +43,7 @@ include(root.BasePaths.RootProfilesDir .. '/chn/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/chn/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/chn/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/chn/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/chn/font.lua'); include(root.BasePaths.RootProfilesDir .. '/chn/dialogue.lua'); diff --git a/profiles/common/achievementnotification.lua b/profiles/common/achievementnotification.lua new file mode 100644 index 000000000..fb2d39be3 --- /dev/null +++ b/profiles/common/achievementnotification.lua @@ -0,0 +1,15 @@ +root.AchievementNotification = { + BackgroundPath = "games/common/achievementnotification.png", + FontPath = "games/common/NotoSansCJKjp-Regular.otf", + DisplayDuration = 5.0, + FadeDuration = 0.5, + IconSize = 64.0, + IconOffset = { X = 20.0, Y = 20.0 }, + TextGap = 20.0, + TextRightPadding = 20.0, + TitleFontSize = 24.0, + DescriptionFontSize = 16.0, + TextLineGap = 6.0, + TextColor = 0xFFFFFF, + OutlineColor = 0x000000, +}; diff --git a/profiles/darling/game.lua b/profiles/darling/game.lua index c114b9cea..6c8dcae4d 100644 --- a/profiles/darling/game.lua +++ b/profiles/darling/game.lua @@ -40,6 +40,7 @@ include(root.BasePaths.RootProfilesDir .. '/darling/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/darling/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/darling/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/darling/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/darling/font.lua'); include(root.BasePaths.RootProfilesDir .. '/darling/dialogue.lua'); diff --git a/profiles/dash/game.lua b/profiles/dash/game.lua index 5ae8d3884..534a30e26 100644 --- a/profiles/dash/game.lua +++ b/profiles/dash/game.lua @@ -39,6 +39,7 @@ include(root.BasePaths.RootProfilesDir .. '/dash/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/dash/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/dash/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/dash/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/dash/font.lua'); include(root.BasePaths.RootProfilesDir .. '/dash/dialogue.lua'); diff --git a/profiles/mo6tw/game.lua b/profiles/mo6tw/game.lua index 01ffb9d25..cdc62f68a 100644 --- a/profiles/mo6tw/game.lua +++ b/profiles/mo6tw/game.lua @@ -37,6 +37,7 @@ include(root.BasePaths.RootProfilesDir .. '/mo6tw/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/mo6tw/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/mo6tw/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/mo6tw/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/mo6tw/font.lua'); include(root.BasePaths.RootProfilesDir .. '/mo6tw/dialogue.lua'); diff --git a/profiles/mo7/game.lua b/profiles/mo7/game.lua index 283a9f5b8..29e84bfbc 100644 --- a/profiles/mo7/game.lua +++ b/profiles/mo7/game.lua @@ -42,6 +42,7 @@ include(root.BasePaths.RootProfilesDir .. '/mo7/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/mo7/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/mo7/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/mo7/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/mo7/font.lua'); include(root.BasePaths.RootProfilesDir .. '/mo7/dialogue.lua'); diff --git a/profiles/mo8/game.lua b/profiles/mo8/game.lua index 07880ad50..d6b7d1b6c 100644 --- a/profiles/mo8/game.lua +++ b/profiles/mo8/game.lua @@ -41,6 +41,7 @@ include(root.BasePaths.RootProfilesDir .. '/mo8/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/mo8/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/mo8/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/mo8/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/mo8/font.lua'); include(root.BasePaths.RootProfilesDir .. '/mo8/dialogue.lua'); diff --git a/profiles/rne/game.lua b/profiles/rne/game.lua index 02e7e52a1..7828787e8 100644 --- a/profiles/rne/game.lua +++ b/profiles/rne/game.lua @@ -36,6 +36,7 @@ include(root.BasePaths.RootProfilesDir .. '/rne/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/rne/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/rne/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/rne/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/rne/font.lua'); include(root.BasePaths.RootProfilesDir .. '/rne/dialogue.lua'); diff --git a/profiles/sgps3/game.lua b/profiles/sgps3/game.lua index 7f8d53d10..5c73bf3df 100644 --- a/profiles/sgps3/game.lua +++ b/profiles/sgps3/game.lua @@ -35,6 +35,7 @@ include(root.BasePaths.RootProfilesDir .. '/sgps3/tipssystem.lua'); include(root.BasePaths.RootProfilesDir .. '/sgps3/vfs.lua'); include(root.BasePaths.RootProfilesDir .. '/sgps3/sprites.lua'); include(root.BasePaths.RootProfilesDir .. '/common/animation.lua'); +include(root.BasePaths.RootProfilesDir .. '/common/achievementnotification.lua'); include(root.BasePaths.RootProfilesDir .. '/sgps3/charset.lua'); include(root.BasePaths.RootProfilesDir .. '/sgps3/font.lua'); include(root.BasePaths.RootProfilesDir .. '/sgps3/dialogue.lua'); diff --git a/src/externalfont.cpp b/src/externalfont.cpp new file mode 100644 index 000000000..cec8e8dc9 --- /dev/null +++ b/src/externalfont.cpp @@ -0,0 +1,224 @@ +#include "font.h" + +#include "io/physicalfilestream.h" +#include "log.h" +#include "renderer/renderer.h" +#include "texture/texture.h" + +#include +#include + +#include +#include FT_FREETYPE_H + +#include +#include +#include +#include + +namespace Impacto { + +struct ExternalFont::Impl { + struct FreeTypeLibraryDeleter { + void operator()(FT_Library library) const { + if (library != nullptr) FT_Done_FreeType(library); + } + }; + + struct FreeTypeFaceDeleter { + void operator()(FT_Face face) const { + if (face != nullptr) FT_Done_Face(face); + } + }; + + struct HarfBuzzFontDeleter { + void operator()(hb_font_t* font) const { + if (font != nullptr) hb_font_destroy(font); + } + }; + + std::unique_ptr, FreeTypeLibraryDeleter> + FreeTypeLibrary; + std::unique_ptr, FreeTypeFaceDeleter> FontFace; + std::unique_ptr HarfBuzzFace; + std::vector FontData; +}; + +ExternalFont::ExternalFont(std::string const& path, + std::string const& logContext) + : FontImpl(new Impl()) { + Io::Stream* stream = nullptr; + IoError err = Io::PhysicalFileStream::Create(path, &stream); + if (err != IoError_OK) { + ImpLog(LogLevel::Error, LogChannel::Profile, "Could not open {:s} {:s}\n", + logContext, path); + return; + } + + FontImpl->FontData.resize(stream->Meta.Size); + int64_t readSize = + stream->Read(FontImpl->FontData.data(), FontImpl->FontData.size()); + delete stream; + + if (readSize != static_cast(FontImpl->FontData.size())) { + ImpLog(LogLevel::Error, LogChannel::Profile, "Could not read {:s} {:s}\n", + logContext, path); + Reset(); + return; + } + + FT_Library ftLibrary = nullptr; + if (FT_Init_FreeType(&ftLibrary) != 0) { + ImpLog(LogLevel::Error, LogChannel::Profile, + "Could not initialize FreeType for {:s}\n", logContext); + Reset(); + return; + } + FontImpl->FreeTypeLibrary.reset(ftLibrary); + + FT_Face face = nullptr; + if (FT_New_Memory_Face( + FontImpl->FreeTypeLibrary.get(), FontImpl->FontData.data(), + static_cast(FontImpl->FontData.size()), 0, &face) != 0) { + ImpLog(LogLevel::Error, LogChannel::Profile, "Could not load {:s} {:s}\n", + logContext, path); + Reset(); + return; + } + FontImpl->FontFace.reset(face); + + FontImpl->HarfBuzzFace.reset( + hb_ft_font_create_referenced(FontImpl->FontFace.get())); + if (!FontImpl->HarfBuzzFace) { + ImpLog(LogLevel::Error, LogChannel::Profile, + "Could not initialize HarfBuzz for {:s}\n", logContext); + Reset(); + } +} + +ExternalFont::~ExternalFont() { + Reset(); + delete FontImpl; +} + +void ExternalFont::Reset() { + FontImpl->HarfBuzzFace.reset(); + FontImpl->FontFace.reset(); + FontImpl->FreeTypeLibrary.reset(); + FontImpl->FontData.clear(); +} + +bool ExternalFont::IsLoaded() const { + return FontImpl->HarfBuzzFace != nullptr && FontImpl->FontFace != nullptr; +} + +std::vector ExternalFont::ShapeLine( + std::string_view text, float fontSize, float& width) { + width = 0.0f; + if (text.empty()) return {}; + assert(IsLoaded()); + + FT_Set_Pixel_Sizes(FontImpl->FontFace.get(), 0, + static_cast(std::round(fontSize))); + hb_ft_font_changed(FontImpl->HarfBuzzFace.get()); + + hb_buffer_t* buffer = hb_buffer_create(); + hb_buffer_add_utf8(buffer, text.data(), static_cast(text.size()), 0, + static_cast(text.size())); + hb_buffer_guess_segment_properties(buffer); + hb_shape(FontImpl->HarfBuzzFace.get(), buffer, nullptr, 0); + + unsigned glyphCount = 0; + hb_glyph_info_t* glyphInfo = hb_buffer_get_glyph_infos(buffer, &glyphCount); + hb_glyph_position_t* glyphPos = + hb_buffer_get_glyph_positions(buffer, &glyphCount); + + std::vector glyphs; + glyphs.reserve(glyphCount); + for (unsigned i = 0; i < glyphCount; i++) { + ExternalFontShapedGlyph glyph{ + .GlyphIndex = glyphInfo[i].codepoint, + .Offset = {static_cast(glyphPos[i].x_offset) / 64.0f, + -static_cast(glyphPos[i].y_offset) / 64.0f}, + .Advance = {static_cast(glyphPos[i].x_advance) / 64.0f, + -static_cast(glyphPos[i].y_advance) / 64.0f}, + }; + width += glyph.Advance.x; + glyphs.push_back(glyph); + } + + hb_buffer_destroy(buffer); + return glyphs; +} + +void ExternalFont::RenderShapedLine( + std::span glyphs, float fontSize, + glm::vec2 origin, glm::vec4 tint, + std::vector& outGlyphs) { + assert(IsLoaded()); + + FT_Set_Pixel_Sizes(FontImpl->FontFace.get(), 0, + static_cast(std::round(fontSize))); + + glm::vec2 pen = origin; + outGlyphs.reserve(outGlyphs.size() + glyphs.size()); + for (ExternalFontShapedGlyph const& glyph : glyphs) { + if (FT_Load_Glyph(FontImpl->FontFace.get(), glyph.GlyphIndex, + FT_LOAD_DEFAULT) != 0) { + ImpLog(LogLevel::Warning, LogChannel::Profile, + "Could not load glyph {:d}\n", glyph.GlyphIndex); + pen += glyph.Advance; + continue; + } + if (FT_Render_Glyph(FontImpl->FontFace.get()->glyph, + FT_RENDER_MODE_NORMAL) != 0) { + ImpLog(LogLevel::Warning, LogChannel::Profile, + "Could not render glyph {:d}\n", glyph.GlyphIndex); + pen += glyph.Advance; + continue; + } + + FT_GlyphSlot slot = FontImpl->FontFace.get()->glyph; + FT_Bitmap const& bitmap = slot->bitmap; + if (bitmap.width == 0 || bitmap.rows == 0) { + pen += glyph.Advance; + continue; + } + + Texture texture; + texture.Init(TexFmt_U8, static_cast(bitmap.width), + static_cast(bitmap.rows)); + for (uint32_t y = 0; y < bitmap.rows; ++y) { + const uint8_t* srcRow = + bitmap.pitch >= 0 + ? bitmap.buffer + y * bitmap.pitch + : bitmap.buffer + (bitmap.rows - 1 - y) * -bitmap.pitch; + std::span dstRow = + std::span(texture.Buffer).subspan(y * bitmap.width, bitmap.width); + std::memcpy(dstRow.data(), srcRow, bitmap.width); + } + + SpriteSheet sheet{static_cast(bitmap.width), + static_cast(bitmap.rows)}; + sheet.Texture = texture.Submit(); + + glm::vec2 glyphPos = + pen + glyph.Offset + glm::vec2(slot->bitmap_left, -slot->bitmap_top); + outGlyphs.push_back({Sprite{sheet, 0, 0, static_cast(bitmap.width), + static_cast(bitmap.rows)}, + glyphPos, tint}); + + pen += glyph.Advance; + } +} + +void ExternalFont::FreeGlyphTextures(std::vector& glyphs) { + for (ExternalFontGlyph const& glyph : glyphs) { + if (glyph.GlyphSprite.Sheet.Texture != 0) { + Renderer->FreeTexture(glyph.GlyphSprite.Sheet.Texture); + } + } + glyphs.clear(); +} + +} // namespace Impacto diff --git a/src/font.h b/src/font.h index 2ba0e2540..c41f9f3e2 100644 --- a/src/font.h +++ b/src/font.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include "spritesheet.h" @@ -274,4 +277,41 @@ class EdgeDetectedSingleSheetFont final : public SingleSheetFont { } }; -} // namespace Impacto \ No newline at end of file +struct ExternalFontShapedGlyph { + uint32_t GlyphIndex; + glm::vec2 Offset; + glm::vec2 Advance; +}; + +struct ExternalFontGlyph { + Sprite GlyphSprite; + glm::vec2 Position; + glm::vec4 Tint; +}; + +class ExternalFont { + public: + ExternalFont(std::string const& path, std::string const& logContext); + ~ExternalFont(); + + ExternalFont(ExternalFont const&) = delete; + ExternalFont& operator=(ExternalFont const&) = delete; + + bool IsLoaded() const; + + std::vector ShapeLine(std::string_view text, + float fontSize, float& width); + void RenderShapedLine(std::span glyphs, + float fontSize, glm::vec2 origin, glm::vec4 tint, + std::vector& outGlyphs); + + static void FreeGlyphTextures(std::vector& glyphs); + + private: + void Reset(); + + struct Impl; + Impl* FontImpl; +}; + +} // namespace Impacto diff --git a/src/game.cpp b/src/game.cpp index a41ed378f..749c5ae9b 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -30,6 +30,7 @@ #include "hud/saveicondisplay.h" #include "hud/loadingdisplay.h" #include "hud/tipsnotification.h" +#include "hud/achievementnotification.h" #include "games/cclcc/systemmenu.h" #include "effects/wave.h" #include "effects/blur.h" @@ -173,6 +174,9 @@ void Init() { Profile::ExtraMenus::Configure(); DateDisplay::Init(); TipsNotification::Init(); + if (+Profile::Game::GameFeatures & +GameFeature::Achievements) { + AchievementNotification::Init(); + } // Default controls Vm::Interface::UpdatePADcustomType(0); @@ -290,6 +294,9 @@ void UpdateSystem(float dt) { SaveIconDisplay::Update(updateInterval); LoadingDisplay::Update(updateInterval); DateDisplay::Update(updateInterval); + if (+Profile::Game::GameFeatures & +GameFeature::Achievements) { + AchievementNotification::Update(updateInterval); + } if (ScrWork[SW_GAMESTATE] & 5 && !GetFlag(SF_GAMEPAUSE)) { UI::GameSpecific::Update(updateInterval); @@ -654,6 +661,9 @@ void Render() { menu->Render(); } } + if (+Profile::Game::GameFeatures & +GameFeature::Achievements) { + AchievementNotification::Render(); + } } if (+Profile::Game::GameFeatures & +GameFeature::CharacterViewer) { @@ -678,4 +688,4 @@ void Render() { } // namespace Game -} // namespace Impacto \ No newline at end of file +} // namespace Impacto diff --git a/src/game.h b/src/game.h index d8055fb99..0cf050af0 100644 --- a/src/game.h +++ b/src/game.h @@ -28,6 +28,7 @@ enum class GameFeature : int { Video = (1 << 8), Subtitles = (1 << 9), DebugMenuMultiViewport = (1 << 10), + Achievements = (1 << 11), }; enum class VideoPlayerType : int { diff --git a/src/hud/achievementnotification.cpp b/src/hud/achievementnotification.cpp new file mode 100644 index 000000000..cef21ca4e --- /dev/null +++ b/src/hud/achievementnotification.cpp @@ -0,0 +1,242 @@ +#include "achievementnotification.h" + +#include "../animation.h" +#include "../data/achievementsystem.h" +#include "../font.h" +#include "../io/physicalfilestream.h" +#include "../log.h" +#include "../profile/game.h" +#include "../profile/hud/achievementnotification.h" +#include "../profile/userconfig.h" +#include "../renderer/renderer.h" +#include "../texture/texture.h" + +#include +#include +#include +#include +#include +#include + +namespace Impacto { +namespace AchievementNotification { + +static Sprite BackgroundSprite; +static std::optional NotificationFont; +static std::vector TextGlyphs; +static Animation FadeAnimation; +static float DisplayTimer = 0.0f; +static std::optional CurrentAchievementId; +static bool IsConfigured = false; +static bool TextConfigured = false; +static std::queue NotificationQueue; + +using namespace Profile::AchievementNotification; + +static void FreeTextGlyphs() { ExternalFont::FreeGlyphTextures(TextGlyphs); } + +static float GetEffectiveScale() { + auto const& config = Profile::UserConfig::CommonSettings; + const float widthScale = + Profile::Game::DesignWidth / static_cast(config.ResolutionWidth); + const float heightScale = + Profile::Game::DesignHeight / static_cast(config.ResolutionHeight); + return std::min(widthScale, heightScale); +} + +static bool LoadBackground(std::string const& path) { + Io::Stream* stream = nullptr; + IoError err = Io::PhysicalFileStream::Create(path, &stream); + if (err != IoError_OK) { + ImpLog(LogLevel::Error, LogChannel::Profile, + "Could not open achievement notification background {:s}\n", path); + return false; + } + + Texture texture; + if (!texture.Load(stream)) { + ImpLog(LogLevel::Error, LogChannel::TextureLoad, + "Could not load achievement notification background {:s}\n", path); + delete stream; + return false; + } + delete stream; + + SpriteSheet sheet(static_cast(texture.Width), + static_cast(texture.Height)); + sheet.Texture = texture.Submit(); + BackgroundSprite = + Sprite(sheet, 0.0f, 0.0f, sheet.DesignWidth, sheet.DesignHeight); + return true; +} + +static void BuildTextLine(std::string const& text, float fontSize, float left, + float baselineY, float availableWidth, + uint32_t color) { + float width = 0.0f; + std::vector glyphs = + NotificationFont->ShapeLine(text, fontSize, width); + if (glyphs.empty()) return; + + float finalFontSize = fontSize; + if (width > availableWidth && width > 0.0f) { + finalFontSize *= availableWidth / width; + glyphs = NotificationFont->ShapeLine(text, finalFontSize, width); + } + + const glm::vec4 textTint = RgbIntToFloat(color); + const glm::vec4 outlineTint = RgbIntToFloat(OutlineColor); + + NotificationFont->RenderShapedLine(glyphs, finalFontSize, + {left + 1.0f, baselineY + 1.0f}, + outlineTint, TextGlyphs); + NotificationFont->RenderShapedLine(glyphs, finalFontSize, {left, baselineY}, + textTint, TextGlyphs); +} + +static void BuildTextGlyphs(AchievementSystem::Achievement const* achievement, + int achievementId) { + FreeTextGlyphs(); + assert(TextConfigured); + + std::string title = achievement != nullptr ? achievement->Name() : ""; + std::string description = + achievement != nullptr ? achievement->Description() : ""; + if (title.empty() && description.empty()) { + title = "Achievement " + std::to_string(achievementId); + description = "Achievement System not implemented."; + } + + const float scale = GetEffectiveScale(); + const float backgroundWidth = BackgroundSprite.ScaledWidth() * scale; + const float backgroundHeight = BackgroundSprite.ScaledHeight() * scale; + const float iconRight = (IconOffset.x + IconSize) * scale; + const float textLeft = iconRight + TextGap * scale; + const float textRight = backgroundWidth - TextRightPadding * scale; + const float centerY = backgroundHeight / 2.0f; + + const float titleFontSize = TitleFontSize * scale; + const float descriptionFontSize = DescriptionFontSize * scale; + const float textLineGap = TextLineGap * scale; + const float titleBaseline = + centerY - (descriptionFontSize + textLineGap) / 2.0f; + const float descriptionBaseline = + centerY + (titleFontSize + textLineGap) / 2.0f; + const float availableWidth = textRight - textLeft; + + BuildTextLine(title, titleFontSize, textLeft, titleBaseline, availableWidth, + TextColor); + BuildTextLine(description, descriptionFontSize, textLeft, descriptionBaseline, + availableWidth, TextColor); +} + +static void StartNextNotification() { + if (NotificationQueue.empty()) return; + + CurrentAchievementId = NotificationQueue.front(); + NotificationQueue.pop(); + + const AchievementSystem::Achievement* achievement = + AchievementSystem::GetAchievement(*CurrentAchievementId); + BuildTextGlyphs(achievement, *CurrentAchievementId); + + DisplayTimer = DisplayDuration; + IsShowing = true; + FadeAnimation.StartIn(true); +} + +void Init() { + FreeTextGlyphs(); + NotificationFont.reset(); + TextConfigured = false; + + Configure(); + + FadeAnimation.DurationIn = FadeDuration; + FadeAnimation.DurationOut = FadeDuration; + FadeAnimation.LoopMode = AnimationLoopMode::Stop; + + if (!LoadBackground(BackgroundPath)) return; + + if (!FontPath.empty()) { + NotificationFont.emplace(FontPath, "achievement notification font"); + TextConfigured = NotificationFont->IsLoaded(); + } else { + ImpLog(LogLevel::Warning, LogChannel::Profile, + "Achievement notification font path is not configured\n"); + } + + IsConfigured = true; +} + +void Update(float dt) { + assert(IsConfigured); + + FadeAnimation.Update(dt); + + if (IsShowing && FadeAnimation.IsIn()) { + DisplayTimer -= dt; + if (DisplayTimer <= 0.0f) { + IsShowing = false; + FadeAnimation.StartOut(); + } + } + + if (!IsShowing && FadeAnimation.IsOut()) { + StartNextNotification(); + } +} + +void Render() { + assert(IsConfigured); + if (FadeAnimation.IsOut()) return; + + glm::vec4 tint(1.0f); + tint.a = glm::smoothstep(0.0f, 1.0f, FadeAnimation.Progress); + + const float scale = GetEffectiveScale(); + const float backgroundWidth = BackgroundSprite.ScaledWidth() * scale; + const float backgroundHeight = BackgroundSprite.ScaledHeight() * scale; + const glm::vec2 pos = {Profile::Game::DesignWidth - backgroundWidth, + Profile::Game::DesignHeight - backgroundHeight}; + + Renderer->DrawSprite(BackgroundSprite, + RectF(pos.x, pos.y, backgroundWidth, backgroundHeight), + tint); + + const AchievementSystem::Achievement* achievement = + CurrentAchievementId.has_value() + ? AchievementSystem::GetAchievement(*CurrentAchievementId) + : nullptr; + if (achievement != nullptr && achievement->Icon().ScaledWidth() > 0.0f && + achievement->Icon().ScaledHeight() > 0.0f) { + const Sprite& icon = achievement->Icon(); + const RectF iconDest(pos.x + IconOffset.x * scale, + pos.y + IconOffset.y * scale, IconSize * scale, + IconSize * scale); + Renderer->DrawSprite(icon, iconDest, tint); + } + + if (!TextGlyphs.empty()) { + Renderer->SetBlendMode(RendererBlendMode::Premultiplied); + for (ExternalFontGlyph const& glyph : TextGlyphs) { + glm::vec4 glyphTint = glyph.Tint; + glyphTint.a *= tint.a; + Renderer->DrawSubtitleGlyph(glyph.GlyphSprite, pos + glyph.Position, + glyphTint); + } + Renderer->SetBlendMode(RendererBlendMode::Normal); + } +} + +void Show(int achievementId) { + assert(IsConfigured); + + NotificationQueue.push(achievementId); + if (!IsShowing && FadeAnimation.IsOut()) { + StartNextNotification(); + } +} + +} // namespace AchievementNotification +} // namespace Impacto diff --git a/src/hud/achievementnotification.h b/src/hud/achievementnotification.h new file mode 100644 index 000000000..3248ca9ee --- /dev/null +++ b/src/hud/achievementnotification.h @@ -0,0 +1,14 @@ +#pragma once + +namespace Impacto { +namespace AchievementNotification { + +inline bool IsShowing = false; + +void Init(); +void Update(float dt); +void Render(); +void Show(int achievementId); + +} // namespace AchievementNotification +} // namespace Impacto diff --git a/src/profile/hud/achievementnotification.cpp b/src/profile/hud/achievementnotification.cpp new file mode 100644 index 000000000..f0de6ff3c --- /dev/null +++ b/src/profile/hud/achievementnotification.cpp @@ -0,0 +1,30 @@ +#include "achievementnotification.h" +#include "../profile_internal.h" + +namespace Impacto { +namespace Profile { +namespace AchievementNotification { + +void Configure() { + EnsurePushMemberOfType("AchievementNotification", LUA_TTABLE); + + BackgroundPath = EnsureGetMember("BackgroundPath"); + FontPath = EnsureGetMember("FontPath"); + DisplayDuration = EnsureGetMember("DisplayDuration"); + FadeDuration = EnsureGetMember("FadeDuration"); + IconSize = EnsureGetMember("IconSize"); + IconOffset = EnsureGetMember("IconOffset"); + TextGap = EnsureGetMember("TextGap"); + TextRightPadding = EnsureGetMember("TextRightPadding"); + TitleFontSize = EnsureGetMember("TitleFontSize"); + DescriptionFontSize = EnsureGetMember("DescriptionFontSize"); + TextLineGap = EnsureGetMember("TextLineGap"); + TextColor = EnsureGetMember("TextColor"); + OutlineColor = EnsureGetMember("OutlineColor"); + + Pop(); +} + +} // namespace AchievementNotification +} // namespace Profile +} // namespace Impacto diff --git a/src/profile/hud/achievementnotification.h b/src/profile/hud/achievementnotification.h new file mode 100644 index 000000000..3ca2f80b3 --- /dev/null +++ b/src/profile/hud/achievementnotification.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace Impacto { +namespace Profile { +namespace AchievementNotification { + +inline std::string BackgroundPath; +inline std::string FontPath; +inline float DisplayDuration; +inline float FadeDuration; +inline float IconSize; +inline glm::vec2 IconOffset; +inline float TextGap; +inline float TextRightPadding; +inline float TitleFontSize; +inline float DescriptionFontSize; +inline float TextLineGap; +inline uint32_t TextColor; +inline uint32_t OutlineColor; + +void Configure(); + +} // namespace AchievementNotification +} // namespace Profile +} // namespace Impacto diff --git a/src/vm/inst_misc.cpp b/src/vm/inst_misc.cpp index e326a2458..39c297d12 100644 --- a/src/vm/inst_misc.cpp +++ b/src/vm/inst_misc.cpp @@ -14,6 +14,7 @@ #include "../data/savesystem.h" #include "../audio/audiosystem.h" #include "../text/dialoguepage.h" +#include "../hud/achievementnotification.h" #include "../profile/vm.h" #include "../games/cc/titlemenu.h" @@ -54,11 +55,13 @@ VmInstruction(InstSetAchievement) { StartInstruction; PopUint8(type); if (type == 1) { - PopExpression(arg1); + PopExpression(achievementId); + AchievementNotification::Show(achievementId); ImpLogSlow(LogLevel::Warning, LogChannel::VMStub, "STUB instruction Achievement(type: {:d}, arg1: {:d})\n", type, - arg1); + achievementId); } else { + AchievementNotification::Show(type); ImpLogSlow(LogLevel::Warning, LogChannel::VMStub, "STUB instruction Achievement(type: {:d})\n", type); } @@ -656,4 +659,4 @@ VmInstruction(InstExitGame) { } // namespace Vm -} // namespace Impacto \ No newline at end of file +} // namespace Impacto diff --git a/vcpkg.json b/vcpkg.json index b154f3227..faac99501 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -41,6 +41,8 @@ }, "libwebp", "fmt", + "freetype", + "harfbuzz", "libass" ], "overrides": [ @@ -53,4 +55,4 @@ "version": "2.32.10" } ] -} \ No newline at end of file +}