diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index a83a278d..a54951e7 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -23,7 +23,39 @@ env: ARTIFACT_SOURCE_TYPE: external_url jobs: + # Windows SMTC bridge DLL. The binaries are not committed to the repository — + # they are built here and injected into src/main/resources before packaging, + # so the shipped jar can never contain a stale binary built from older C++. + build-native: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Build fpsmaster-smtc (x64 + x86) + shell: pwsh + run: | + cmake -S native/smtc -B native/smtc/build-x64 -A x64 + cmake --build native/smtc/build-x64 --config Release + cmake -S native/smtc -B native/smtc/build-win32 -A Win32 + cmake --build native/smtc/build-win32 --config Release + + - name: Collect DLLs + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist/x64, dist/x86 | Out-Null + Copy-Item native/smtc/build-x64/Release/fpsmaster-smtc.dll dist/x64/ + Copy-Item native/smtc/build-win32/Release/fpsmaster-smtc.dll dist/x86/ + Get-ChildItem -Recurse dist | Select-Object FullName, Length + + - name: Upload native artifacts + uses: actions/upload-artifact@v4 + with: + name: smtc-native + path: dist + if-no-files-found: error + build: + needs: build-native runs-on: ubuntu-latest outputs: should_publish: ${{ steps.decision.outputs.should_publish }} @@ -133,6 +165,26 @@ jobs: if: steps.decision.outputs.should_build == 'true' uses: gradle/actions/setup-gradle@v3 + - name: Inject SMTC native libraries + if: steps.decision.outputs.should_build == 'true' + uses: actions/download-artifact@v4 + with: + name: smtc-native + path: src/main/resources/native/windows + + - name: Verify native libraries are present + if: steps.decision.outputs.should_build == 'true' + shell: bash + run: | + for arch in x64 x86; do + dll="src/main/resources/native/windows/$arch/fpsmaster-smtc.dll" + if [ ! -s "$dll" ]; then + echo "Missing or empty $dll — the jar would silently ship without SMTC" >&2 + exit 1 + fi + echo "$arch: $(stat -c%s "$dll") bytes, sha256 $(sha256sum "$dll" | awk '{print $1}')" + done + - name: Build with Gradle Wrapper if: steps.decision.outputs.should_build == 'true' shell: bash diff --git a/.gitignore b/.gitignore index 4e6b98b8..1ffa21a8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ remeasure.ps1 /usercache.json /usernamecache.json /FPSMaster Edge/ + +# SMTC 原生库由 CI 构建后注入(native/smtc/CMakeLists.txt),不提交二进制 +src/main/resources/native/windows/*/*.dll +native/smtc/build-*/ diff --git a/build.gradle.kts b/build.gradle.kts index 4df0ef13..193ec570 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -123,6 +123,10 @@ dependencies { shadowImpl("com.google.zxing:core:3.5.3") { isTransitive = false } + // Windows SMTC 桥接:JNA 加载原生 DLL 调用 WinRT。 + shadowImpl("net.java.dev.jna:jna:5.17.0") { + isTransitive = true + } // If you don't want to log in with your real minecraft account, remove this line runtimeOnly("me.djtheredstoner:DevAuth-forge-legacy:1.1.2") compileOnly("org.projectlombok:lombok:1.18.38") diff --git a/docs/tasks.md b/docs/tasks.md index 9d91fb7a..864d071f 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -31,12 +31,14 @@ FPSMaster v4将会在此列表任务大部分完成后发布 - [ ] 全局搜索 - [x] 新的OOBE - [ ] 功能完整的饰品界面 -- [ ] AutoText -- [ ] 饱和度显示 +- [x] AutoText +- [x] 饱和度显示 - [ ] 账号管理界面 -- [ ] SMTC支持 +- [x] SMTC支持 - [ ] 时间更改模块改为世界更改,添加天气选项(是否和WorldColor模块合并?) - [ ] 修复歌词渲染等特殊组件可能因glflags状态导致的渲染问题 +- [ ] 添加异步光照性能优化 +- [x] 添加自定义迷雾(颜色,距离等) - [ ] 按[性能优化路线图](performance-roadmap.md)推进区块、实体、光照、纹理和画质裁剪工作 - [ ] 添加自定义迷雾(颜色,距离等) - [ ] 为客户端的组件颜色选择添加彩色选项 diff --git a/native/smtc/CMakeLists.txt b/native/smtc/CMakeLists.txt new file mode 100644 index 00000000..97b4a0cb --- /dev/null +++ b/native/smtc/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.20) +project(fpsmaster-smtc CXX) + +# Windows-only: the whole point of this target is the WinRT SMTC bridge. +if(NOT WIN32) + message(FATAL_ERROR "fpsmaster-smtc is a Windows-only target") +endif() + +# C++20, not 17: C++/WinRT's headers pull in coroutine support, and under /std:c++17 +# that resolves to , which current MSVC rejects outright +# (STL1011). C++20 gives it the standard instead. +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Architecture is selected by the generator platform, e.g. +# cmake -B build-x64 -A x64 +# cmake -B build-win32 -A Win32 +add_library(fpsmaster-smtc SHARED + smtc.cpp + smtc.def +) + +set_target_properties(fpsmaster-smtc PROPERTIES + PREFIX "" + OUTPUT_NAME "fpsmaster-smtc" +) + +target_compile_definitions(fpsmaster-smtc PRIVATE + UNICODE + _UNICODE + WIN32_LEAN_AND_MEAN + NOMINMAX +) + +if(MSVC) + # /EHsc: C++/WinRT throws; /MT: static CRT so the DLL does not need the + # VC++ redistributable on the player's machine. + target_compile_options(fpsmaster-smtc PRIVATE /EHsc /W3) + set_property(TARGET fpsmaster-smtc PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded") +endif() + +# WindowsApp.lib provides the WinRT activation/interop entry points. +target_link_libraries(fpsmaster-smtc PRIVATE WindowsApp) diff --git a/native/smtc/smtc.cpp b/native/smtc/smtc.cpp new file mode 100644 index 00000000..9ef952b7 --- /dev/null +++ b/native/smtc/smtc.cpp @@ -0,0 +1,325 @@ +// fpsmaster-smtc - Windows System Media Transport Controls bridge DLL +// C++/WinRT native library for FPSMaster Edge music integration. +// +// Build: see CMakeLists.txt in this directory (cmake -A x64|Win32 && cmake --build). +// CI builds both architectures in .github/workflows/ci-release.yml; the +// resulting DLLs are injected into src/main/resources/native/windows/ at +// package time and are deliberately NOT committed to the repository. +// +// The DLL exports a tiny C ABI that the Java side (JNA) calls. +// Callbacks from SMTC button events are dispatched to a function pointer +// provided by the Java side. +// +// Desktop (Win32 JVM) integration goes through the +// ISystemMediaTransportControlsInterop::GetForWindow(hwnd) path. The UWP +// GetForCurrentView() API requires a CoreWindow and therefore always fails +// in the Minecraft JVM; it is deliberately not used here. +// +// Threading: every export is called from a single dedicated Java thread +// ("FPSMaster-SMTC"), never from the game thread. That thread owns the WinRT +// apartment this DLL initializes, so the game thread is never joined to an +// apartment it did not ask for. + +#include +#include +#include +#include +#include +#include + +// WinRT headers +#include +#include +#include +#include + +using namespace winrt; +using namespace Windows::Media; +using namespace Windows::Storage::Streams; + +// --------------------------------------------------------------------------- +// Callback types (Java sets these via JNA) +// --------------------------------------------------------------------------- +typedef void(__cdecl* ControlCallback)(int action); +static ControlCallback g_callback = nullptr; + +// --------------------------------------------------------------------------- +// SMTC session state +// --------------------------------------------------------------------------- +static SystemMediaTransportControls g_smtc = nullptr; +static winrt::event_token g_buttonToken{}; +static bool g_initialized = false; +static bool g_apartmentOwned = false; + +// Last failure reason, surfaced to Java through smtc_get_last_error so a bug +// report can tell "no window found" apart from "GetForWindow returned E_FAIL". +static wchar_t g_lastError[256] = {0}; + +static void set_last_error(const wchar_t* msg) { + if (!msg) { + g_lastError[0] = 0; + return; + } + wcsncpy_s(g_lastError, msg, _TRUNCATE); +} + +static void set_last_error_hr(const wchar_t* msg, HRESULT hr) { + swprintf_s(g_lastError, L"%s (hr=0x%08X)", msg, (unsigned int)hr); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +namespace { + +struct WindowSearch { + DWORD pid; + HWND result; +}; + +// Only accept a real top-level application window: owned by this process, +// visible, no owner window, and with a caption. Without these filters the very +// first EnumWindows hit during Forge start-up can be the splash screen or a +// stray AWT frame, and SMTC would bind its session to a window that is about to +// be destroyed. +BOOL CALLBACK enum_window_proc(HWND hwnd, LPARAM lParam) { + auto* search = reinterpret_cast(lParam); + DWORD wpid = 0; + GetWindowThreadProcessId(hwnd, &wpid); + if (wpid != search->pid) return TRUE; + if (!IsWindowVisible(hwnd)) return TRUE; + if (GetWindow(hwnd, GW_OWNER) != nullptr) return TRUE; + if (GetWindowTextLengthW(hwnd) == 0) return TRUE; + + search->result = hwnd; + return FALSE; +} + +} // namespace + +// Find a top-level window owned by this process. GetForWindow needs a real +// HWND; the Java side passes 0 only when it could not obtain the LWJGL handle. +static HWND find_process_window() { + WindowSearch search{GetCurrentProcessId(), nullptr}; + EnumWindows(enum_window_proc, reinterpret_cast(&search)); + return search.result; +} + +// --------------------------------------------------------------------------- +// C ABI exports +// --------------------------------------------------------------------------- + +extern "C" { + +/// Initialize the SMTC session for a desktop window. Call once; idempotent. +/// hwnd: parent window handle (0 = auto-detect the process main window). +/// Returns 1 on success, 0 on failure (so the Java side can surface it). +__declspec(dllexport) int __cdecl smtc_start(HWND hwnd) { + if (g_initialized) return 1; + set_last_error(nullptr); + try { + // Initialize the WinRT apartment on the calling thread (the dedicated + // Java SMTC thread). MTA so button callbacks can fire on any thread and + // we never need a message pump. + try { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + g_apartmentOwned = true; + } catch (winrt::hresult_error const& e) { + // RPC_E_CHANGED_MODE means the thread was already initialized as an + // STA by someone else. SMTC works from an STA too, so carry on — + // but do not claim ownership, since we must not uninitialize it. + if (static_cast(e.code()) != RPC_E_CHANGED_MODE) { + set_last_error_hr(L"init_apartment failed", static_cast(e.code())); + g_initialized = false; + return 0; + } + g_apartmentOwned = false; + } + + HWND target = hwnd; + if (target == nullptr) { + target = find_process_window(); + } + if (target == nullptr) { + set_last_error(L"no top-level window found for this process"); + g_initialized = false; + return 0; + } + + // Desktop interop: get the transport controls bound to a window. + auto interop = get_activation_factory(); + SystemMediaTransportControls controls = nullptr; + HRESULT hr = interop->GetForWindow(target, + winrt::guid_of(), + winrt::put_abi(controls)); + if (FAILED(hr)) { + set_last_error_hr(L"GetForWindow failed", hr); + g_initialized = false; + return 0; + } + g_smtc = controls; + if (!g_smtc) { + set_last_error(L"GetForWindow returned a null interface"); + g_initialized = false; + return 0; + } + + // Enable all supported buttons + g_smtc.IsPlayEnabled(true); + g_smtc.IsPauseEnabled(true); + g_smtc.IsNextEnabled(true); + g_smtc.IsPreviousEnabled(true); + + // Subscribe to button events + g_buttonToken = g_smtc.ButtonPressed( + [](const SystemMediaTransportControls& sender, + const SystemMediaTransportControlsButtonPressedEventArgs& args) { + if (!g_callback) return; + switch (args.Button()) { + case SystemMediaTransportControlsButton::Play: + case SystemMediaTransportControlsButton::Pause: + g_callback(1); // play/pause toggle + break; + case SystemMediaTransportControlsButton::Next: + g_callback(2); + break; + case SystemMediaTransportControlsButton::Previous: + g_callback(3); + break; + case SystemMediaTransportControlsButton::Stop: + g_callback(4); + break; + } + }); + + g_initialized = true; + return 1; + } catch (winrt::hresult_error const& e) { + set_last_error_hr(L"smtc_start threw", static_cast(e.code())); + g_initialized = false; + return 0; + } catch (...) { + set_last_error(L"smtc_start threw an unknown exception"); + g_initialized = false; + return 0; + } +} + +/// Register the Java callback function pointer. +__declspec(dllexport) void __cdecl smtc_set_callback(ControlCallback cb) { + g_callback = cb; +} + +/// Publish playback metadata and state. +/// artwork_data / artwork_len: raw image bytes (PNG/JPEG/...) or NULL/0 for no art. +/// The stream is handed to RandomAccessStreamReference as-is; Windows decodes it. +__declspec(dllexport) void __cdecl smtc_publish( + const wchar_t* title, + const wchar_t* artist, + const wchar_t* album, + int64_t positionMs, + int64_t durationMs, + bool playing, + bool hasCurrentTrack, + const unsigned char* artwork_data, + int artwork_len +) { + if (!g_smtc) return; + + try { + auto updater = g_smtc.DisplayUpdater(); + updater.Type(MediaPlaybackType::Music); + updater.MusicProperties().Title(winrt::hstring(title)); + updater.MusicProperties().Artist(winrt::hstring(artist)); + updater.MusicProperties().AlbumTitle(winrt::hstring(album)); + updater.Thumbnail(nullptr); + + // Set album art if available + if (artwork_data && artwork_len > 0) { + try { + auto stream = InMemoryRandomAccessStream(); + auto buffer = Buffer(static_cast(artwork_len)); + memcpy(buffer.data(), artwork_data, artwork_len); + // Buffer(capacity) 只设置 Capacity,Length 仍是 0;WriteAsync 只写 Length 个字节, + // 不显式设置 Length 会写出一个 0 字节的流(缩略图永远空白) + buffer.Length(static_cast(artwork_len)); + stream.WriteAsync(buffer).get(); + stream.Seek(0); + auto ref = RandomAccessStreamReference::CreateFromStream(stream); + updater.Thumbnail(ref); + } catch (...) { + // Artwork is best-effort + } + } + + updater.Update(); + + // Timeline properties + auto props = SystemMediaTransportControlsTimelineProperties(); + props.StartTime(std::chrono::milliseconds(0)); + props.Position(std::chrono::milliseconds(positionMs)); + props.EndTime(std::chrono::milliseconds(durationMs > 0 ? durationMs : 1)); + props.MinSeekTime(std::chrono::milliseconds(0)); + props.MaxSeekTime(std::chrono::milliseconds(durationMs > 0 ? durationMs : 1)); + g_smtc.UpdateTimelineProperties(props); + + // Playback status + if (!hasCurrentTrack) { + g_smtc.PlaybackStatus(MediaPlaybackStatus::Stopped); + } else if (playing) { + g_smtc.PlaybackStatus(MediaPlaybackStatus::Playing); + } else { + g_smtc.PlaybackStatus(MediaPlaybackStatus::Paused); + } + } catch (...) { + // Best-effort + } +} + +/// Enable/disable which buttons are shown. +__declspec(dllexport) void __cdecl smtc_set_buttons( + bool playPause, bool next, bool prev +) { + if (!g_smtc) return; + try { + g_smtc.IsPlayEnabled(playPause); + g_smtc.IsPauseEnabled(playPause); + g_smtc.IsNextEnabled(next); + g_smtc.IsPreviousEnabled(prev); + } catch (...) { + } +} + +/// Release the SMTC session. Safe to call multiple times. +__declspec(dllexport) void __cdecl smtc_close() { + if (!g_initialized) return; + try { + if (g_smtc) { + g_smtc.ButtonPressed(g_buttonToken); + g_smtc = nullptr; + } + g_initialized = false; + } catch (...) { + g_initialized = false; + } + g_callback = nullptr; + // Only leave the apartment if smtc_start is the one that entered it. + if (g_apartmentOwned) { + g_apartmentOwned = false; + try { + winrt::uninit_apartment(); + } catch (...) { + } + } +} + +/// Copy the last failure reason into buf (wide, NUL-terminated). Empty if the +/// last operation succeeded. +__declspec(dllexport) void __cdecl smtc_get_last_error(wchar_t* buf, int bufLen) { + if (!buf || bufLen <= 0) return; + wcsncpy_s(buf, (size_t)bufLen, g_lastError, _TRUNCATE); +} + +} // extern "C" \ No newline at end of file diff --git a/native/smtc/smtc.def b/native/smtc/smtc.def new file mode 100644 index 00000000..52597d75 --- /dev/null +++ b/native/smtc/smtc.def @@ -0,0 +1,7 @@ +EXPORTS + smtc_start + smtc_set_callback + smtc_publish + smtc_set_buttons + smtc_close + smtc_get_last_error diff --git a/src/main/java/top/fpsmaster/FPSMaster.java b/src/main/java/top/fpsmaster/FPSMaster.java index 2ddaaa7d..2766d18e 100644 --- a/src/main/java/top/fpsmaster/FPSMaster.java +++ b/src/main/java/top/fpsmaster/FPSMaster.java @@ -142,6 +142,12 @@ public void shutdown() { // Before anything else: an unfinished stream is an unreadable file. top.fpsmaster.replay.ReplayRecorder.instance().stop(); PlayTimeStatistics.flush(); + // Release SMTC native session before tearing down async/audio + try { + top.fpsmaster.modules.music.MusicManager.get().shutdownSmtc(); + } catch (Throwable t) { + ClientLogger.warn("SMTC shutdown skipped: " + t.getMessage()); + } telemetryReporter.shutdown(); async.close(); try { diff --git a/src/main/java/top/fpsmaster/features/impl/interfaces/SaturationDisplay.java b/src/main/java/top/fpsmaster/features/impl/interfaces/SaturationDisplay.java new file mode 100644 index 00000000..e37ebd6a --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/interfaces/SaturationDisplay.java @@ -0,0 +1,33 @@ +package top.fpsmaster.features.impl.interfaces; + +import top.fpsmaster.features.impl.InterfaceModule; +import top.fpsmaster.features.manager.Category; +import top.fpsmaster.features.settings.impl.ColorSetting; + +import java.awt.Color; + +/** + * 把原版隐藏的饱和度显示成一根条加数值。原版饥饿条照常渲染,不受影响。 + */ +public class SaturationDisplay extends InterfaceModule { + public static boolean using; + + public static ColorSetting barColor = new ColorSetting("BarColor", new Color(255, 190, 60)); + + public SaturationDisplay() { + super("SaturationDisplay", Category.Interface, Trait.BACKGROUND, Trait.TEXT); + addSettings(barColor); + } + + @Override + public void onEnable() { + using = true; + super.onEnable(); + } + + @Override + public void onDisable() { + using = false; + super.onDisable(); + } +} diff --git a/src/main/java/top/fpsmaster/features/impl/render/BlockOverlay.java b/src/main/java/top/fpsmaster/features/impl/render/BlockOverlay.java index a35f8437..d40bca03 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/BlockOverlay.java +++ b/src/main/java/top/fpsmaster/features/impl/render/BlockOverlay.java @@ -51,6 +51,7 @@ public void onRender3D(EventRender3D e) { // Use raw world for block state access if (Minecraft.getMinecraft().theWorld != null && Minecraft.getMinecraft().objectMouseOver != null) { BlockPos mcPos = Minecraft.getMinecraft().objectMouseOver.getBlockPos(); + if (mcPos == null) return; IBlockState state = Minecraft.getMinecraft().theWorld.getBlockState(mcPos); Block block = state.getBlock(); diff --git a/src/main/java/top/fpsmaster/features/impl/render/CustomFog.java b/src/main/java/top/fpsmaster/features/impl/render/CustomFog.java new file mode 100644 index 00000000..f26deb12 --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/render/CustomFog.java @@ -0,0 +1,38 @@ +package top.fpsmaster.features.impl.render; + +import top.fpsmaster.features.manager.Category; +import top.fpsmaster.features.manager.Module; +import top.fpsmaster.features.settings.impl.BooleanSetting; +import top.fpsmaster.features.settings.impl.ColorSetting; +import top.fpsmaster.features.settings.impl.NumberSetting; +import top.fpsmaster.features.settings.impl.ModeSetting; + +import java.awt.*; + +public class CustomFog extends Module { + public static boolean using; + + public static ColorSetting color = new ColorSetting("Color", new Color(0, 200, 255)); + public static NumberSetting startDistance = new NumberSetting("StartDistance", 32.0, 0, 200, 1); + public static NumberSetting endDistance = new NumberSetting("EndDistance", 64, 1, 200, 1); + public static ModeSetting fogMode = new ModeSetting("FogMode", 0, "Linear", "Exponential"); + public static BooleanSetting affectWater = new BooleanSetting("AffectWater", false); + public static BooleanSetting affectLava = new BooleanSetting("AffectLava", false); + + public CustomFog() { + super("CustomFog", Category.RENDER); + addSettings(color, fogMode, startDistance, endDistance, affectWater, affectLava); + } + + @Override + public void onEnable() { + using = true; + super.onEnable(); + } + + @Override + public void onDisable() { + using = false; + super.onDisable(); + } +} \ No newline at end of file diff --git a/src/main/java/top/fpsmaster/features/impl/utility/AutoText.java b/src/main/java/top/fpsmaster/features/impl/utility/AutoText.java new file mode 100644 index 00000000..28f8fb30 --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/utility/AutoText.java @@ -0,0 +1,53 @@ +package top.fpsmaster.features.impl.utility; + +import top.fpsmaster.event.Subscribe; +import top.fpsmaster.event.events.EventKey; +import top.fpsmaster.features.manager.Category; +import top.fpsmaster.features.manager.Module; +import top.fpsmaster.features.settings.impl.AutoTextEntry; +import top.fpsmaster.features.settings.impl.AutoTextSetting; +import top.fpsmaster.utils.core.Utility; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Editable shortcut-to-chat entries. Each entry binds a key to a message. + * When the key is pressed (and the module is enabled, the screen is null, + * and the player is alive), the message is sent via {@link Utility#sendChatMessage}. + * + *

Default: G → gg. Cap: 20 entries. Duplicate key bindings are rejected at the editor. + */ +public class AutoText extends Module { + private static final ArrayList DEFAULT = new ArrayList<>(); + static { + DEFAULT.add(new AutoTextEntry(0x22 /* G key LWJGL */, "gg")); + } + + public final AutoTextSetting entries = new AutoTextSetting("Entries", new ArrayList<>(DEFAULT)); + + public AutoText() { + super("AutoText", Category.Utility); + addSettings(entries); + } + + @Subscribe + public void onKey(EventKey e) { + if (e.key == 0) return; + if (net.minecraft.client.Minecraft.getMinecraft().currentScreen != null) return; + if (net.minecraft.client.Minecraft.getMinecraft().thePlayer == null) return; + + List snapshot; + synchronized (entries) { + snapshot = new ArrayList<>(entries.getValue()); + } + + for (AutoTextEntry entry : snapshot) { + if (entry.keyCode == e.key && !entry.message.isEmpty()) { + Utility.sendChatMessage(entry.message); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/top/fpsmaster/features/manager/ModuleManager.java b/src/main/java/top/fpsmaster/features/manager/ModuleManager.java index b85ca9eb..7c7426e9 100644 --- a/src/main/java/top/fpsmaster/features/manager/ModuleManager.java +++ b/src/main/java/top/fpsmaster/features/manager/ModuleManager.java @@ -94,6 +94,7 @@ public void init() { modules.add(new MoreParticles()); modules.add(new ChatAvatars()); modules.add(new FPSDisplay()); + modules.add(new SaturationDisplay()); modules.add(new ArmorDisplay()); modules.add(new BetterChat()); modules.add(new BlockIndicator()); @@ -114,6 +115,7 @@ public void init() { modules.add(new FireModifier()); modules.add(new FreeLook()); modules.add(new AutoGG()); + modules.add(new AutoText()); modules.add(new TimeChanger()); modules.add(new TNTTimer()); modules.add(new Hitboxes()); @@ -122,6 +124,7 @@ public void init() { modules.add(new Crosshair()); modules.add(new CustomTitles()); modules.add(new CustomFOV()); + modules.add(new CustomFog()); modules.add(new InventoryDisplay()); modules.add(new PlayerDisplay()); modules.add(new TargetDisplay()); diff --git a/src/main/java/top/fpsmaster/features/settings/Setting.java b/src/main/java/top/fpsmaster/features/settings/Setting.java index 2c7ed689..6510840c 100644 --- a/src/main/java/top/fpsmaster/features/settings/Setting.java +++ b/src/main/java/top/fpsmaster/features/settings/Setting.java @@ -2,6 +2,7 @@ import top.fpsmaster.event.EventDispatcher; import top.fpsmaster.event.events.EventValueChange; +import top.fpsmaster.features.settings.impl.AutoTextEntry; import top.fpsmaster.features.settings.impl.utils.CustomColor; import java.util.ArrayList; @@ -95,7 +96,16 @@ private T copyValue(T source) { return (T) ((CustomColor) source).copy(); } if (source instanceof ArrayList) { - return (T) new ArrayList<>((ArrayList) source); + ArrayList src = (ArrayList) source; + // AutoTextEntry lists need deep copies so each entry is immutable. + if (!src.isEmpty() && src.get(0) instanceof AutoTextEntry) { + ArrayList copy = new ArrayList<>(src.size()); + for (Object o : src) { + copy.add(new AutoTextEntry((AutoTextEntry) o)); + } + return (T) copy; + } + return (T) new ArrayList<>(src); } return source; } diff --git a/src/main/java/top/fpsmaster/features/settings/impl/AutoTextEntry.java b/src/main/java/top/fpsmaster/features/settings/impl/AutoTextEntry.java new file mode 100644 index 00000000..25bfca9b --- /dev/null +++ b/src/main/java/top/fpsmaster/features/settings/impl/AutoTextEntry.java @@ -0,0 +1,22 @@ +package top.fpsmaster.features.settings.impl; + +/** + * One AutoText entry: a key that sends {@code message} when pressed. + * + *

Immutable on purpose — entries are stored inside the {@code ArrayList} a {@link AutoTextSetting} + * owns, and {@link top.fpsmaster.features.settings.Setting#copyValue(Object)} shallow-copies that list. + * With immutable entries the copy is a safe deep copy, so reset-to-default can never leak edits. + */ +public final class AutoTextEntry { + public final int keyCode; + public final String message; + + public AutoTextEntry(int keyCode, String message) { + this.keyCode = keyCode; + this.message = message == null ? "" : message; + } + + public AutoTextEntry(AutoTextEntry other) { + this(other.keyCode, other.message); + } +} diff --git a/src/main/java/top/fpsmaster/features/settings/impl/AutoTextSetting.java b/src/main/java/top/fpsmaster/features/settings/impl/AutoTextSetting.java new file mode 100644 index 00000000..5b999b1e --- /dev/null +++ b/src/main/java/top/fpsmaster/features/settings/impl/AutoTextSetting.java @@ -0,0 +1,85 @@ +package top.fpsmaster.features.settings.impl; + +import top.fpsmaster.features.settings.Setting; + +import java.util.ArrayList; +import java.util.List; + +/** + * A setting that stores an ordered list of {@link AutoTextEntry}s exposed to the ClickGUI. + * + *

Each entry bundles a key code and a chat message. The list is capped at 20, and duplicate + * key codes (other than 0) are rejected at the editor level. This class enforces the cap and + * provides shallow-copy snapshots for the reset-to-default lifecycle. + */ +public class AutoTextSetting extends Setting> { + public static final int MAX_CAPACITY = 20; + + public AutoTextSetting(String name, ArrayList defaultValue) { + super(name, clamp(defaultValue)); + } + + public AutoTextSetting(String name, ArrayList defaultValue, VisibleCondition visible) { + super(name, clamp(defaultValue), visible); + } + + private static ArrayList clamp(ArrayList list) { + if (list == null) { + list = new ArrayList<>(); + } + if (list.size() > MAX_CAPACITY) { + list = new ArrayList<>(list.subList(0, MAX_CAPACITY)); + } + return list; + } + + /** Returns true if the entry was added at the end. */ + public boolean addEntry(AutoTextEntry entry) { + ArrayList current = getValue(); + if (current.size() >= MAX_CAPACITY) { + return false; + } + ArrayList oldSnapshot = new ArrayList<>(current); + ArrayList newSnapshot = new ArrayList<>(current); + newSnapshot.add(new AutoTextEntry(entry)); + if (!fireValueChangeEvent(oldSnapshot, newSnapshot)) { + return false; + } + current.add(new AutoTextEntry(entry)); + notifyChangeListeners(oldSnapshot, newSnapshot); + return true; + } + + /** Removes the entry at {@code index}, no-op if out of bounds. */ + public void removeEntry(int index) { + ArrayList current = getValue(); + if (index < 0 || index >= current.size()) { + return; + } + ArrayList oldSnapshot = new ArrayList<>(current); + ArrayList newSnapshot = new ArrayList<>(current); + newSnapshot.remove(index); + if (!fireValueChangeEvent(oldSnapshot, newSnapshot)) { + return; + } + current.remove(index); + notifyChangeListeners(oldSnapshot, newSnapshot); + } + + /** Replaces the entry at {@code index}. Returns false if the edit was rejected. */ + public boolean editEntry(int index, AutoTextEntry entry) { + ArrayList current = getValue(); + if (index < 0 || index >= current.size()) { + return false; + } + ArrayList oldSnapshot = new ArrayList<>(current); + ArrayList newSnapshot = new ArrayList<>(current); + newSnapshot.set(index, new AutoTextEntry(entry)); + if (!fireValueChangeEvent(oldSnapshot, newSnapshot)) { + return false; + } + current.set(index, new AutoTextEntry(entry)); + notifyChangeListeners(oldSnapshot, newSnapshot); + return true; + } +} \ No newline at end of file diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinEntityRenderer.java b/src/main/java/top/fpsmaster/forge/mixin/MixinEntityRenderer.java index 69851871..541d0fd4 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinEntityRenderer.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinEntityRenderer.java @@ -13,12 +13,15 @@ import net.minecraft.client.shader.ShaderGroup; import net.minecraft.entity.Entity; import net.minecraft.entity.EntityLivingBase; +import net.minecraft.potion.Potion; import net.minecraft.entity.item.EntityItemFrame; import net.minecraft.entity.passive.EntityAnimal; import net.minecraft.util.*; import net.minecraftforge.client.ForgeHooksClient; import net.minecraftforge.client.event.EntityViewRenderEvent; import net.minecraftforge.common.MinecraftForge; +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Overwrite; import org.spongepowered.asm.mixin.Shadow; @@ -36,8 +39,10 @@ import top.fpsmaster.features.impl.optimizes.SmoothZoom; import top.fpsmaster.features.impl.render.FreeLook; import top.fpsmaster.features.impl.render.MinimizedBobbing; +import top.fpsmaster.features.impl.render.CustomFog; import top.fpsmaster.utils.math.MathUtils; +import java.nio.FloatBuffer; import java.util.List; import static top.fpsmaster.utils.core.Utility.mc; @@ -301,6 +306,80 @@ public void freelook(float partialTicks, long nanoTime, CallbackInfo ci) { FreeLook.overrideMouse(); } + @Shadow + private float fogColorRed; + @Shadow + private float fogColorGreen; + @Shadow + private float fogColorBlue; + + @Shadow + private FloatBuffer setFogColorBuffer(float red, float green, float blue, float alpha) { + throw new AssertionError(); + } + + /** + * CustomFog 是否应该接管当前这一帧的雾。 + * + *

失明药水那条是硬性的:原版会把雾强制成极短的线性雾,覆盖它等于解除视野限制, + * 在服务器上属于优势而不是外观改动。 + */ + @Unique + private boolean fpsmaster$shouldOverrideFog() { + if (!CustomFog.using) return false; + if (mc.thePlayer == null) return false; + if (mc.thePlayer.isPotionActive(Potion.blindness)) return false; + if (!CustomFog.affectWater.getValue() && mc.thePlayer.isInsideOfMaterial(Material.water)) return false; + if (!CustomFog.affectLava.getValue() && mc.thePlayer.isInsideOfMaterial(Material.lava)) return false; + return true; + } + + /** + * 天空和清屏色走的是 fogColorRed/Green/Blue 这三个字段(renderWorldPass 拿它 glClearColor, + * renderSky 拿它画天空和地平线的雾带),跟 setupFog 里的 GL_FOG_COLOR 是两套状态。只改后者 + * 会得到"方块被自定义雾吃掉、天空还是原版蓝"的割裂画面,所以这里一并覆盖。 + */ + @Inject(method = "updateFogColor", at = @At("RETURN")) + private void overrideFogColor(float partialTicks, CallbackInfo ci) { + if (!fpsmaster$shouldOverrideFog()) return; + java.awt.Color fogColor = CustomFog.color.getColor(); + fogColorRed = fogColor.getRed() / 255f; + fogColorGreen = fogColor.getGreen() / 255f; + fogColorBlue = fogColor.getBlue() / 255f; + } + + @Inject(method = "setupFog", at = @At("RETURN")) + private void overrideFog(int startCoords, float partialTicks, CallbackInfo ci) { + if (!fpsmaster$shouldOverrideFog()) return; + + // 雾色只能用 glFog(GL_FOG_COLOR, buffer) 设置,不能走顶点色。 + // 复用原版的 setFogColorBuffer:它写的是 EntityRenderer 自己缓存的 direct buffer, + // 而 setupFog 每帧要跑好几次,这里再新建 buffer 会持续制造 direct 内存分配。 + java.awt.Color fogColor = CustomFog.color.getColor(); + GL11.glFog(GL11.GL_FOG_COLOR, setFogColorBuffer( + fogColor.getRed() / 255f, + fogColor.getGreen() / 255f, + fogColor.getBlue() / 255f, + 1.0f)); + + if (CustomFog.fogMode.isMode("Linear")) { + GlStateManager.setFog(GL11.GL_LINEAR); + float start = CustomFog.startDistance.getValue().floatValue(); + float end = CustomFog.endDistance.getValue().floatValue(); + // 防反转:end 必须比 start 大,否则线性雾公式 (end - z)/(end - start) 会反过来, + // 近处反而更浓。顺带把范围钳制在渲染距离内,避免雾一直延伸到可视边界外。 + if (end <= start) { + end = start + 1.0f; + } + GlStateManager.setFogStart(start); + GlStateManager.setFogEnd(end); + } else { + GlStateManager.setFog(GL11.GL_EXP); + float density = 1.0f / Math.max(0.1f, CustomFog.endDistance.getValue().floatValue()); + GlStateManager.setFogDensity(density); + } + } + @Shadow private Entity pointedEntity; diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinSplashScreen.java b/src/main/java/top/fpsmaster/forge/mixin/MixinSplashScreen.java index 07e9e756..a44c65d9 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinSplashScreen.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinSplashScreen.java @@ -22,7 +22,7 @@ public class MixinSplashScreen { * @author SuperSkidder * @reason Custom Loading Screen */ - @Overwrite + @Overwrite(remap = false) public static void start() { ScaledResolution scaledresolution = new ScaledResolution(Minecraft.getMinecraft()); int i = scaledresolution.getScaleFactor(); diff --git a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java index 78a70f70..cb06e84a 100644 --- a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java +++ b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java @@ -11,13 +11,7 @@ import top.fpsmaster.features.impl.render.ItemPhysics; import top.fpsmaster.features.manager.Module; import top.fpsmaster.features.settings.Setting; -import top.fpsmaster.features.settings.impl.BindSetting; -import top.fpsmaster.features.settings.impl.BooleanSetting; -import top.fpsmaster.features.settings.impl.ColorSetting; -import top.fpsmaster.features.settings.impl.ModeSetting; -import top.fpsmaster.features.settings.impl.MultipleItemSetting; -import top.fpsmaster.features.settings.impl.NumberSetting; -import top.fpsmaster.features.settings.impl.TextSetting; +import top.fpsmaster.features.settings.impl.*; import top.fpsmaster.features.settings.impl.utils.CustomColor; import top.fpsmaster.modules.config.migration.ConfigMigration; import top.fpsmaster.modules.config.migration.ConfigMigrationRegistry; @@ -117,6 +111,19 @@ public void saveConfig(String name) throws FileException { settingJson.addProperty("type", "multiItem"); settingJson.add("value", items); settingsJson.add(setting.name, settingJson); + } else if (setting instanceof AutoTextSetting) { + AutoTextSetting autoText = (AutoTextSetting) setting; + JsonArray arr = new JsonArray(); + for (AutoTextEntry entry : autoText.getValue()) { + JsonObject obj = new JsonObject(); + obj.addProperty("key", entry.keyCode); + obj.addProperty("msg", entry.message); + arr.add(obj); + } + JsonObject settingJson = new JsonObject(); + settingJson.addProperty("type", "autoText"); + settingJson.add("value", arr); + settingsJson.add(setting.name, settingJson); } else { JsonObject settingJson = new JsonObject(); if (setting instanceof BooleanSetting) { @@ -343,6 +350,27 @@ public void loadConfig(String name) throws Exception { } } multipleItemSetting.setValue(items); + } else if (setting instanceof AutoTextSetting && "autoText".equals(type)) { + AutoTextSetting autoText = (AutoTextSetting) setting; + ArrayList entries = new ArrayList<>(); + java.util.Set usedKeys = new java.util.HashSet<>(); + for (JsonElement entryElement : value.getAsJsonArray()) { + if (entries.size() >= AutoTextSetting.MAX_CAPACITY) { + break; + } + try { + JsonObject obj = entryElement.getAsJsonObject(); + int key = obj.has("key") ? obj.get("key").getAsInt() : 0; + String msg = obj.has("msg") ? obj.get("msg").getAsString() : ""; + if (key != 0 && !usedKeys.add(key)) { + continue; // duplicate key: drop + } + entries.add(new AutoTextEntry(key, msg)); + } catch (Throwable t) { + ClientLogger.warn("Skipping malformed AutoText entry in " + module.name + "/" + setting.name); + } + } + autoText.setValue(entries); } } } catch (Throwable throwable) { diff --git a/src/main/java/top/fpsmaster/modules/music/MusicManager.java b/src/main/java/top/fpsmaster/modules/music/MusicManager.java index 9885f502..6caf4c16 100644 --- a/src/main/java/top/fpsmaster/modules/music/MusicManager.java +++ b/src/main/java/top/fpsmaster/modules/music/MusicManager.java @@ -91,6 +91,9 @@ private static java.nio.file.Path resolveAuthFile() { private final MusicOverlay overlay = new MusicOverlay(this); private volatile boolean showLyricsInGame = false; + // 系统媒体传输控件(Windows SMTC):平台不可用时自动降级为 no-op。 + private final top.fpsmaster.modules.music.smtc.SmtcMusicBridge smtcBridge; + // 二维码登录轮询 private volatile Thread qrThread; private volatile QrCode qrCode; @@ -98,6 +101,51 @@ private static java.nio.file.Path resolveAuthFile() { public MusicManager() { routeLogging(); + // Initialize SMTC bridge (no-op on non-Windows or when native fails) + smtcBridge = new top.fpsmaster.modules.music.smtc.SmtcMusicBridge( + this, + top.fpsmaster.modules.music.smtc.SystemMediaTransportControlsFactory.create( + new top.fpsmaster.modules.music.smtc.MediaControlListener() { + @Override + public void onPlayPause() { + post(new Runnable() { + @Override + public void run() { + togglePause(); + } + }); + } + @Override + public void onNext() { + post(new Runnable() { + @Override + public void run() { + next(); + } + }); + } + @Override + public void onPrevious() { + post(new Runnable() { + @Override + public void run() { + prev(); + } + }); + } + @Override + public void onStop() { + post(new Runnable() { + @Override + public void run() { + engine().stop(); + } + }); + } + } + ) + ); + smtcBridge.start(); try { store.load(); String cookie = store.getNeteaseCookie(); @@ -586,4 +634,11 @@ private void post(Runnable r) { r.run(); } } + + /** Releases the SMTC bridge and its native session. Safe to call multiple times. */ + public void shutdownSmtc() { + if (smtcBridge != null) { + smtcBridge.stop(); + } + } } diff --git a/src/main/java/top/fpsmaster/modules/music/MusicTextures.java b/src/main/java/top/fpsmaster/modules/music/MusicTextures.java index 7f444ae2..4ce2378a 100644 --- a/src/main/java/top/fpsmaster/modules/music/MusicTextures.java +++ b/src/main/java/top/fpsmaster/modules/music/MusicTextures.java @@ -14,6 +14,8 @@ import java.awt.Color; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.Base64; @@ -48,6 +50,15 @@ public final class MusicTextures { return t; }); + // 网络下载单独一个池。串行的约束只来自 AWT,HTTP 没有这个问题,混在 IMG_EXEC 上会让一次 + // 超时(连接 10s + 读 15s)把后面所有解码堵死——扫码登录的二维码明明不需要联网,却要排在 + // 某个封面下载后面等最多 25 秒。 + private static final ExecutorService NET_EXEC = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "FPSMaster-Music-Net"); + t.setDaemon(true); + return t; + }); + private MusicTextures() { } @@ -59,16 +70,33 @@ public static synchronized ResourceLocation cover(final String url) { if (loc != null) return loc; if (LOADING.contains(key)) return null; LOADING.add(key); - IMG_EXEC.execute(new Runnable() { + NET_EXEC.execute(new Runnable() { @Override public void run() { + final byte[] bytes; try { - BufferedImage img = downloadImage(url); - upload(key, img); + bytes = downloadBytes(url); } catch (Throwable e) { - ClientLogger.error("Music cover load failed: " + e.getMessage()); + ClientLogger.error("Music cover download failed: " + e.getMessage()); unmark(key); + return; } + if (bytes == null) { + unmark(key); + return; + } + // 拿到字节之后才回到串行线程解码 + IMG_EXEC.execute(new Runnable() { + @Override + public void run() { + try { + upload(key, ImageIO.read(new ByteArrayInputStream(bytes))); + } catch (Throwable e) { + ClientLogger.error("Music cover decode failed: " + e.getMessage()); + unmark(key); + } + } + }); } }); return null; @@ -104,8 +132,7 @@ public void run() { } /** 由文本(网易云登录 codekey URL)生成二维码纹理。 */ - public static synchronized ResourceLocation qr(final String text) { - if (text == null || text.isEmpty()) return null; + public static synchronized ResourceLocation qr(final String text) { if (text == null || text.isEmpty()) return null; final String key = "qr:" + text; ResourceLocation loc = READY.get(key); if (loc != null) return loc; @@ -135,6 +162,33 @@ public static synchronized void invalidate(String rawKey) { } } + /** + * 下载图片的原始字节,结果通过回调返回;下载失败传 {@code null}。 + * + *

供 SMTC 这类只要字节、不要 GL 纹理的调用方使用。这条路径完全不碰 AWT:原先它是 + * 「下载 → ImageIO.read 解成 BufferedImage → ImageIO.write 编回 PNG」,而 SMTC 的 + * {@code RandomAccessStreamReference} 吃的就是图片流,Windows 自己会解码,JPEG 也认—— + * 那一读一写既是白做的,又把这条路径绑上了 {@link #IMG_EXEC} 的串行队列。 + * + *

回调在 {@link #NET_EXEC} 上执行,不要在里面做阻塞的事。 + */ + public static void downloadBytesAsync(final String url, final java.util.function.Consumer callback) { + if (url == null || url.isEmpty() || callback == null) { + return; + } + NET_EXEC.execute(new Runnable() { + @Override + public void run() { + try { + callback.accept(downloadBytes(url)); + } catch (Throwable e) { + ClientLogger.error("Music image download failed: " + e.getMessage()); + callback.accept(null); + } + } + }); + } + private static synchronized void unmark(String key) { LOADING.remove(key); } @@ -181,14 +235,21 @@ private static BufferedImage toArgb(BufferedImage src) { return out; } - private static BufferedImage downloadImage(String url) throws Exception { + /** 纯网络 IO,不碰 AWT——调用方拿到字节后自行决定在哪解码。 */ + private static byte[] downloadBytes(String url) throws Exception { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setInstanceFollowRedirects(true); conn.setConnectTimeout(10_000); conn.setReadTimeout(15_000); conn.setRequestProperty("User-Agent", UA); - try { - return ImageIO.read(conn.getInputStream()); + try (InputStream in = conn.getInputStream()) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + bos.write(buf, 0, n); + } + return bos.size() == 0 ? null : bos.toByteArray(); } finally { conn.disconnect(); } diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/MediaControlListener.java b/src/main/java/top/fpsmaster/modules/music/smtc/MediaControlListener.java new file mode 100644 index 00000000..9e094879 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/MediaControlListener.java @@ -0,0 +1,17 @@ +package top.fpsmaster.modules.music.smtc; + +/** + * Receives transport-control events from the system media UI (Windows SMTC buttons). + * + *

Implementations must not touch Minecraft or music state directly — the bridge may + * invoke these callbacks on a native/COM thread. The caller schedules them onto the main thread. + */ +public interface MediaControlListener { + void onPlayPause(); + + void onNext(); + + void onPrevious(); + + void onStop(); +} diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/MediaPlaybackSnapshot.java b/src/main/java/top/fpsmaster/modules/music/smtc/MediaPlaybackSnapshot.java new file mode 100644 index 00000000..2f26df9b --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/MediaPlaybackSnapshot.java @@ -0,0 +1,36 @@ +package top.fpsmaster.modules.music.smtc; + +/** + * A single immutable snapshot of the currently playing track, as exposed to the system media + * transport (Windows SMTC). Values are read on the main thread and handed to the bridge, which + * is free to coalesce/throttle them. + * + *

{@link #artworkBytes} carries the raw album art bytes when available (downloaded off-thread), + * or {@code null} when no art has loaded yet. Whatever the server served — PNG, JPEG — is passed + * through untouched; Windows decodes the stream itself. + */ +public final class MediaPlaybackSnapshot { + public final String title; + public final String artist; + public final String album; + public final long positionMs; + public final long durationMs; + public final boolean playing; + public final boolean hasCurrentTrack; + public final byte[] artworkBytes; + + public MediaPlaybackSnapshot( + String title, String artist, String album, + long positionMs, long durationMs, + boolean playing, boolean hasCurrentTrack, + byte[] artworkBytes) { + this.title = title == null ? "" : title; + this.artist = artist == null ? "" : artist; + this.album = album == null ? "" : album; + this.positionMs = Math.max(0, positionMs); + this.durationMs = Math.max(0, durationMs); + this.playing = playing; + this.hasCurrentTrack = hasCurrentTrack; + this.artworkBytes = artworkBytes; + } +} diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/NoopSystemMediaTransportControls.java b/src/main/java/top/fpsmaster/modules/music/smtc/NoopSystemMediaTransportControls.java new file mode 100644 index 00000000..1e4a67bc --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/NoopSystemMediaTransportControls.java @@ -0,0 +1,24 @@ +package top.fpsmaster.modules.music.smtc; + +/** + * No-op transport facade used on platforms where SMTC is unavailable (non-Windows), or when the + * Windows bridge fails to load. Keeps the music system fully functional with zero side effects. + */ +final class NoopSystemMediaTransportControls implements SystemMediaTransportControls { + @Override + public void start() { + } + + @Override + public void publish(MediaPlaybackSnapshot snapshot) { + } + + @Override + public void close() { + } + + @Override + public boolean isAvailable() { + return false; + } +} diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/SmtcMusicBridge.java b/src/main/java/top/fpsmaster/modules/music/smtc/SmtcMusicBridge.java new file mode 100644 index 00000000..6765e498 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/SmtcMusicBridge.java @@ -0,0 +1,169 @@ +package top.fpsmaster.modules.music.smtc; + +import top.fpsmaster.modules.logger.ClientLogger; +import top.fpsmaster.modules.music.MusicManager; +import top.fpsmaster.modules.music.MusicTextures; +import top.fpsmaster.music.Track; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Bridges the music player into the system media transport. + * + *

Runs a lightweight polling loop (separate daemon thread) that samples the current track and + * forwards a snapshot to the transport facade. Control events from the system are marshalled back + * onto the Minecraft main thread before calling {@link MusicManager} so queue/UI state stays + * main-thread consistent. Cover art is downloaded on the shared single-threaded + * {@link MusicTextures} decoder queue to avoid concurrent AWT/ImageIO crashes on macOS. + */ +public final class SmtcMusicBridge { + + private final MusicManager music; + private final SystemMediaTransportControls controls; + private final AtomicReference artwork = new AtomicReference<>(); + private volatile String artworkUrl; + + private volatile boolean running; + private Thread pollThread; + private volatile Track lastTrack; + + public SmtcMusicBridge(MusicManager music, SystemMediaTransportControls controls) { + this.music = music; + this.controls = controls; + } + + /** Starts the polling loop (idempotent). Does not block the caller. */ + public void start() { + if (running) { + return; + } + running = true; + controls.start(); + pollThread = new Thread(this::pollLoop, "FPSMaster-Smtc-Poll"); + pollThread.setDaemon(true); + pollThread.start(); + } + + public void stop() { + running = false; + Thread t = pollThread; + if (t != null) { + t.interrupt(); + } + pollThread = null; + controls.close(); + } + + private void pollLoop() { + long lastPos = -1; + boolean lastPlaying = false; + while (running) { + try { + Track cur = music.getCurrent(); + if (cur == null) { + if (lastTrack != null || lastPos >= 0) { + // No current track → publish cleared state + controls.publish(new MediaPlaybackSnapshot("", "", "", 0, 0, false, false, null)); + lastTrack = null; + lastPos = -1; + lastPlaying = false; + } + Thread.sleep(500); + continue; + } + + if (cur != lastTrack) { + lastTrack = cur; + lastPos = -1; + // New track: kick off cover download (cache is keyed by URL, so a track change + // always refreshes the art instead of reusing the previous track's thumbnail) + requestArtwork(cur.getCoverUrl()); + } + + long pos = music.engine().getPositionMs(); + long dur = music.engine().getDurationMs(); + if (dur <= 0) { + dur = cur.getDurationMs(); + } + boolean playing = music.engine().isPlaying(); + + boolean positionChanged = Math.abs(pos - lastPos) >= 500 || lastPos == -1; + boolean stateChanged = playing != lastPlaying; + if (positionChanged || stateChanged) { + lastPos = pos; + lastPlaying = playing; + controls.publish(new MediaPlaybackSnapshot( + cur.getName(), + cur.getArtists(), + "", + pos, + dur, + playing, + true, + artwork.get() + )); + } + + Thread.sleep(250); + } catch (InterruptedException e) { + return; + } catch (Throwable t) { + ClientLogger.error("SMTC poll error: " + t.getMessage()); + try { + Thread.sleep(500); + } catch (InterruptedException ie) { + return; + } + } + } + } + + /** + * Requests album art for {@code coverUrl}. Empty/duplicate URLs clear or keep the cache; any + * other URL triggers a re-download because the previous track's art no longer matches. + */ + private void requestArtwork(final String coverUrl) { + if (coverUrl == null || coverUrl.isEmpty()) { + artwork.set(null); + artworkUrl = null; + return; + } + if (coverUrl.equals(artworkUrl)) { + return; + } + artworkUrl = coverUrl; + // 换曲目就立刻丢掉上一首的封面,否则新歌的标题会配着旧歌的图发布出去 + artwork.set(null); + MusicTextures.downloadBytesAsync(coverUrl, png -> { + // Only accept the result if the track hasn't changed while we were downloading + if (!coverUrl.equals(artworkUrl)) { + return; + } + // png 为 null(404/超时)时也要落库,保持"无封面"而不是留着上一首的图 + artwork.set(png); + controls.publish(snapshotFromCurrent()); + }); + } + + private MediaPlaybackSnapshot snapshotFromCurrent() { + Track cur = music.getCurrent(); + if (cur == null) { + return new MediaPlaybackSnapshot("", "", "", 0, 0, false, false, null); + } + long pos = music.engine().getPositionMs(); + long dur = music.engine().getDurationMs(); + if (dur <= 0) { + dur = cur.getDurationMs(); + } + return new MediaPlaybackSnapshot( + cur.getName(), + cur.getArtists(), + "", + pos, + dur, + music.engine().isPlaying(), + true, + artwork.get() + ); + } +} diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/SystemMediaTransportControls.java b/src/main/java/top/fpsmaster/modules/music/smtc/SystemMediaTransportControls.java new file mode 100644 index 00000000..b9789864 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/SystemMediaTransportControls.java @@ -0,0 +1,24 @@ +package top.fpsmaster.modules.music.smtc; + +/** + * Platform-neutral facade for the system media transport controls (SMTC on Windows). + * + *

On non-Windows platforms the returned no-op implementation is used, so the music system and + * the rest of the client behave exactly as before. The concrete bridge only activates after an + * explicit {@code os.name} Windows check and a successful native-library load; every public method + * degrades gracefully (no-op) if the bridge is unavailable. + */ +public interface SystemMediaTransportControls { + + /** Initializes the transport. Safe to call multiple times; idempotent. */ + void start(); + + /** Publishes the current playback snapshot. Safe/no-op when unavailable. */ + void publish(MediaPlaybackSnapshot snapshot); + + /** Releases the transport and its session. Safe/no-op when never started. */ + void close(); + + /** True when this bridge actually backed the transport (Windows + load success). */ + boolean isAvailable(); +} diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/SystemMediaTransportControlsFactory.java b/src/main/java/top/fpsmaster/modules/music/smtc/SystemMediaTransportControlsFactory.java new file mode 100644 index 00000000..dfd5db87 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/SystemMediaTransportControlsFactory.java @@ -0,0 +1,38 @@ +package top.fpsmaster.modules.music.smtc; + +import top.fpsmaster.modules.logger.ClientLogger; + +/** + * Factory that picks the real Windows SMTC bridge when the OS is Windows and the native library + * loads, and a no-op otherwise. Never throws from the factory path — the caller (music) must never + * crash because of media integration. + */ +public final class SystemMediaTransportControlsFactory { + + private SystemMediaTransportControlsFactory() { + } + + /** + * Creates the platform-appropriate transport. + * + * @param listener control-event listener; may be invoked on a native thread, schedule it to the + * main thread before touching Minecraft/music state. + */ + public static SystemMediaTransportControls create(MediaControlListener listener) { + try { + String os = System.getProperty("os.name", ""); + if (os.toLowerCase(java.util.Locale.ROOT).contains("win")) { + try { + return new WindowsSystemMediaTransportControls(listener); + } catch (Throwable t) { + ClientLogger.error("Windows SMTC bridge unavailable, using no-op: " + t.getMessage()); + return new NoopSystemMediaTransportControls(); + } + } + return new NoopSystemMediaTransportControls(); + } catch (Throwable t) { + ClientLogger.error("SMTC factory error, using no-op: " + t.getMessage()); + return new NoopSystemMediaTransportControls(); + } + } +} diff --git a/src/main/java/top/fpsmaster/modules/music/smtc/WindowsSystemMediaTransportControls.java b/src/main/java/top/fpsmaster/modules/music/smtc/WindowsSystemMediaTransportControls.java new file mode 100644 index 00000000..7e4d1f02 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/music/smtc/WindowsSystemMediaTransportControls.java @@ -0,0 +1,270 @@ +package top.fpsmaster.modules.music.smtc; + +import com.sun.jna.Callback; +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.WString; +import top.fpsmaster.modules.logger.ClientLogger; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Windows System Media Transport Controls bridge backed by the bundled {@code fpsmaster-smtc.dll} + * native library. The DLL is shipped inside the mod jar at {@code native/windows/fpsmaster-smtc.dll} + * and extracted to a per-run temp directory on first access. + * + *

If the OS is not Windows, the DLL is missing, or any native call fails, the instance degrades + * to a safe no-op. Never blocks the render thread, and never crashes music or the game. + */ +final class WindowsSystemMediaTransportControls implements SystemMediaTransportControls { + + private interface SmtcNative extends Library { + int smtc_start(Pointer hwnd); + void smtc_set_callback(ControlCallback cb); + void smtc_publish(WString title, WString artist, WString album, long positionMs, long durationMs, + boolean playing, boolean hasCurrentTrack, byte[] artworkData, int artworkLen); + void smtc_set_buttons(boolean playPause, boolean next, boolean prev); + void smtc_close(); + void smtc_get_last_error(Pointer buf, int bufLen); + } + + private interface ControlCallback extends Callback { + void invoke(int action); + } + + private static final AtomicReference API = new AtomicReference<>(null); + private static boolean extractionAttempted = false; + + /** + * Every native call goes through this single thread. WinRT apartment state is per-thread: + * initializing it on the game thread would either fail (when something already made that + * thread an STA) or permanently join the game thread to the MTA. Owning a thread of our own + * also keeps the blocking {@code smtc_publish} off the caller's thread. + */ + private static final ExecutorService EXEC = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "FPSMaster-SMTC"); + t.setDaemon(true); + return t; + }); + + private final AtomicBoolean available = new AtomicBoolean(false); + /** Latest snapshot waiting to be published; newer ones overwrite older, so nothing queues up. */ + private final AtomicReference pending = new AtomicReference<>(null); + private final MediaControlListener listener; + private final ControlCallback callback; + + WindowsSystemMediaTransportControls(MediaControlListener listener) { + this.listener = listener; + this.callback = action -> { + try { + if (listener != null) { + switch (action) { + case 1: listener.onPlayPause(); break; + case 2: listener.onNext(); break; + case 3: listener.onPrevious(); break; + case 4: listener.onStop(); break; + default: break; + } + } + } catch (Throwable t) { + ClientLogger.error("SMTC control callback error: " + t.getMessage()); + } + }; + loadAndInit(); + } + + private static synchronized SmtcNative resolveNative() { + SmtcNative existing = API.get(); + if (existing != null) { + return existing; + } + if (extractionAttempted) { + return null; + } + extractionAttempted = true; + + try { + extractAndLoad(); + SmtcNative lib = Native.load("fpsmaster-smtc", SmtcNative.class); + API.compareAndSet(null, lib); + return lib; + } catch (Throwable t) { + ClientLogger.error("SMTC native load failed: " + t.getMessage()); + return null; + } + } + + private static void extractAndLoad() throws Exception { + String bitness = System.getProperty("os.arch", "").contains("64") ? "x64" : "x86"; + String resourcePath = "/native/windows/" + bitness + "/fpsmaster-smtc.dll"; + + File extractDir = new File(System.getProperty("java.io.tmpdir", ""), "fpsmaster-smtc"); + extractDir.mkdirs(); + File dllFile = new File(extractDir, "fpsmaster-smtc.dll"); + + // Extract from classpath + try (InputStream in = WindowsSystemMediaTransportControls.class.getResourceAsStream(resourcePath)) { + if (in == null) { + ClientLogger.warn("SMTC DLL not found in classpath: " + resourcePath); + throw new Exception("DLL resource not found: " + resourcePath); + } + try (FileOutputStream out = new FileOutputStream(dllFile)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + } + } + + // Add to JNA search path + NativeLibrary.addSearchPath("fpsmaster-smtc", extractDir.getAbsolutePath()); + } + + /** + * The HWND of the LWJGL game window. SMTC's desktop interop binds the session to a specific + * window, and letting the native side guess via EnumWindows can pick the Forge splash screen, + * which is destroyed moments later. LWJGL 2 keeps the handle private, hence the reflection; + * failing here is not fatal — the native side falls back to its (filtered) window search. + */ + private static Pointer resolveGameWindow() { + try { + Method getImpl = Class.forName("org.lwjgl.opengl.Display").getDeclaredMethod("getImplementation"); + getImpl.setAccessible(true); + Object impl = getImpl.invoke(null); + if (impl == null || !impl.getClass().getName().endsWith("WindowsDisplay")) { + return null; + } + Field hwndField = impl.getClass().getDeclaredField("hwnd"); + hwndField.setAccessible(true); + long hwnd = hwndField.getLong(impl); + return hwnd == 0L ? null : new Pointer(hwnd); + } catch (Throwable t) { + ClientLogger.warn("SMTC could not resolve the LWJGL window handle: " + t.getMessage()); + return null; + } + } + + /** Reads the native failure reason; empty when the DLL has nothing to report. */ + private static String lastNativeError(SmtcNative nativeLib) { + try { + Memory buf = new Memory(256L * Native.WCHAR_SIZE); + nativeLib.smtc_get_last_error(buf, 256); + String msg = buf.getWideString(0); + return msg == null ? "" : msg; + } catch (Throwable t) { + return ""; + } + } + + private void loadAndInit() { + // Resolve the window handle on the caller's thread: LWJGL's Display state belongs to the + // game thread, and the native call itself is what has to move off it. + final Pointer hwnd = resolveGameWindow(); + EXEC.execute(() -> { + try { + SmtcNative nativeLib = resolveNative(); + if (nativeLib == null) { + return; + } + nativeLib.smtc_set_callback(callback); + int status = nativeLib.smtc_start(hwnd); + if (status != 1) { + String err = lastNativeError(nativeLib); + ClientLogger.warn("SMTC native start failed, SMTC unavailable" + + (err.isEmpty() ? "" : ": " + err)); + return; + } + available.set(true); + } catch (Throwable t) { + ClientLogger.error("SMTC native start failed: " + t.getMessage()); + available.set(false); + } + }); + } + + @Override + public void start() { + // loadAndInit already ran smtc_start on the SMTC thread and the native side is idempotent; + // nothing to redo here. + } + + @Override + public void publish(MediaPlaybackSnapshot snapshot) { + if (!available.get() || snapshot == null) { + return; + } + // Publishing is a blocking WinRT call. Hand the newest snapshot to the SMTC thread and + // return immediately; if one is still in flight the newer snapshot simply replaces it. + boolean queued = pending.getAndSet(snapshot) != null; + if (queued) { + return; + } + EXEC.execute(() -> { + MediaPlaybackSnapshot latest = pending.getAndSet(null); + SmtcNative nativeLib = API.get(); + if (latest == null || nativeLib == null || !available.get()) { + return; + } + try { + boolean hasTrack = latest.hasCurrentTrack; + nativeLib.smtc_set_buttons(hasTrack, hasTrack, hasTrack); + byte[] art = latest.artworkBytes; + nativeLib.smtc_publish( + new WString(latest.title), + new WString(latest.artist), + new WString(latest.album), + latest.positionMs, + latest.durationMs, + latest.playing, + hasTrack, + art, + art == null ? 0 : art.length + ); + } catch (Throwable t) { + ClientLogger.error("SMTC publish failed: " + t.getMessage()); + } + }); + } + + @Override + public void close() { + if (!available.compareAndSet(true, false)) { + return; + } + pending.set(null); + // Wait briefly: this runs during client shutdown and the session should be released before + // the process exits, but a hung native call must not hold the game open. + try { + EXEC.submit(() -> { + try { + SmtcNative nativeLib = API.get(); + if (nativeLib != null) { + nativeLib.smtc_close(); + } + } catch (Throwable t) { + ClientLogger.error("SMTC close failed: " + t.getMessage()); + } + }).get(2, TimeUnit.SECONDS); + } catch (Throwable t) { + ClientLogger.warn("SMTC close did not finish in time: " + t.getMessage()); + } + } + + @Override + public boolean isAvailable() { + return available.get(); + } +} \ No newline at end of file diff --git a/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java b/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java index 83e64839..c5fdd520 100644 --- a/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java +++ b/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java @@ -97,6 +97,8 @@ public ModuleRenderer(Module module) { settingsRenderers.add(new BindSettingRender(module, (BindSetting) setting)); } else if(setting instanceof MultipleItemSetting) { settingsRenderers.add(new MultipleItemSettingRender(module,(MultipleItemSetting)setting)); + } else if(setting instanceof AutoTextSetting) { + settingsRenderers.add(new AutoTextSettingRender(module,(AutoTextSetting)setting)); } }); } diff --git a/src/main/java/top/fpsmaster/ui/click/modules/impl/AutoTextSettingRender.java b/src/main/java/top/fpsmaster/ui/click/modules/impl/AutoTextSettingRender.java new file mode 100644 index 00000000..9309a8f3 --- /dev/null +++ b/src/main/java/top/fpsmaster/ui/click/modules/impl/AutoTextSettingRender.java @@ -0,0 +1,220 @@ +package top.fpsmaster.ui.click.modules.impl; + +import org.lwjgl.input.Keyboard; +import top.fpsmaster.FPSMaster; +import top.fpsmaster.features.manager.Module; +import top.fpsmaster.features.settings.impl.AutoTextEntry; +import top.fpsmaster.features.settings.impl.AutoTextSetting; +import top.fpsmaster.ui.click.ClickGuiTheme; +import top.fpsmaster.ui.click.MainPanel; +import top.fpsmaster.ui.click.modules.SettingRender; +import top.fpsmaster.ui.common.TextField; +import top.fpsmaster.utils.render.draw.Hover; +import top.fpsmaster.utils.render.draw.Rects; +import top.fpsmaster.utils.render.gui.ScaledGuiScreen; + +import java.awt.*; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * ClickGUI editor for {@link AutoTextSetting}. + * + *

Each row: a bind button, a message text field, and a delete (×) button. + * A centered + button below the last row adds a new entry (up to 20). + * Duplicate non-zero bindings are rejected with an inline warning. + */ +public class AutoTextSettingRender extends SettingRender { + private static final int ROW_H = 16; + private static final int CORNER = 3; + + private int capturingRow = -1; + private String duplicateWarning = ""; + private final ArrayList textFields = new ArrayList<>(); + + public AutoTextSettingRender(Module module, AutoTextSetting setting) { + super(setting); + this.mod = module; + rebuildTextFields(); + } + + private void rebuildTextFields() { + textFields.clear(); + for (AutoTextEntry entry : setting.getValue()) { + TextField tf = new TextField(FPSMaster.fontManager.s16, false, "", + ClickGuiTheme.textFieldBg().getRGB(), ClickGuiTheme.textFieldText().getRGB(), 256); + tf.setText(entry.message); + textFields.add(tf); + } + } + + @Override + public void render(ScaledGuiScreen screen, float x, float y, float width, float height, float mouseX, float mouseY, boolean custom) { + ArrayList entries = setting.getValue(); + + // Rebuild text fields if entry count changed externally + while (textFields.size() < entries.size()) { + TextField tf = new TextField(FPSMaster.fontManager.s16, false, "", + ClickGuiTheme.textFieldBg().getRGB(), ClickGuiTheme.textFieldText().getRGB(), 256); + tf.setText(entries.get(textFields.size()).message); + textFields.add(tf); + } + while (textFields.size() > entries.size()) { + textFields.remove(textFields.size() - 1); + } + + // Sync text field contents back to entries only when the field is focused + for (int i = 0; i < entries.size() && i < textFields.size(); i++) { + TextField tf = textFields.get(i); + if (tf.isFocused()) { + String currentText = tf.getText(); + if (!currentText.equals(entries.get(i).message)) { + setting.editEntry(i, new AutoTextEntry(entries.get(i).keyCode, currentText)); + } + } + } + + duplicateWarning = ""; + + // Detect duplicate keys + Set usedKeys = new HashSet<>(); + for (int i = 0; i < entries.size(); i++) { + int k = entries.get(i).keyCode; + if (k != 0) { + if (!usedKeys.add(k)) { + duplicateWarning = "Duplicate key: " + Keyboard.getKeyName(k); + } + } + } + + float rowX = x + 10; + float rowY = y + 2; + + FPSMaster.fontManager.s16.drawString( + FPSMaster.i18n.get((mod.name + "." + setting.name).toLowerCase(java.util.Locale.getDefault())), + rowX, rowY, ClickGuiTheme.textSecondary().getRGB() + ); + rowY += 14; + + if (entries.isEmpty()) { + FPSMaster.fontManager.s14.drawString( + FPSMaster.i18n.get("autotext.empty"), + rowX + (width - 20 - FPSMaster.fontManager.s14.getStringWidth(FPSMaster.i18n.get("autotext.empty"))) / 2, + rowY + 4, ClickGuiTheme.textSecondary().getRGB() + ); + this.height = 38; + } else { + for (int i = 0; i < entries.size(); i++) { + AutoTextEntry entry = entries.get(i); + float rX = rowX + 8; + float rY = rowY + i * (ROW_H + 3); + + // Bind button + String keyName = entry.keyCode != 0 ? Keyboard.getKeyName(entry.keyCode) : "None"; + float bindW = FPSMaster.fontManager.s16.getStringWidth(keyName) + 6; + boolean isCapturing = capturingRow == i; + Color bindBg = isCapturing + ? ClickGuiTheme.bindBgActive() + : (Hover.is(rX, rY, bindW, ROW_H, (int) mouseX, (int) mouseY) + ? ClickGuiTheme.bindBgInactive() : ClickGuiTheme.textFieldBg()); + Rects.rounded(Math.round(rX), Math.round(rY), Math.round(bindW), ROW_H, CORNER, bindBg); + FPSMaster.fontManager.s16.drawString(keyName, rX + 3, rY + 3, ClickGuiTheme.textPrimary().getRGB()); + + ScaledGuiScreen.PointerEvent bindClick = screen.consumePressInBounds(rX, rY, bindW, ROW_H, 0); + if (bindClick != null) { + capturingRow = (capturingRow == i) ? -1 : i; + MainPanel.bindLock = capturingRow >= 0 ? (setting.name + i) : ""; + } + + // Text field + float tfX = rX + bindW + 4; + float tfW = width - 20 - bindW - 4 - 14; + TextField tf = textFields.get(i); + tf.drawTextBox(tfX, rY, tfW, ROW_H); + + ScaledGuiScreen.PointerEvent tfClick = screen.consumePressInBounds(tfX, rY, tfW, ROW_H, 0); + if (tfClick != null) { + tf.mouseClicked((int) tfClick.x, (int) tfClick.y, 0); + capturingRow = -1; + } + + // Delete button + float delX = tfX + tfW + 2; + boolean delHover = Hover.is(delX, rY, 12, ROW_H, (int) mouseX, (int) mouseY); + Rects.rounded(Math.round(delX), Math.round(rY), 12, ROW_H, CORNER, + delHover ? ClickGuiTheme.buttonHoverBg() : ClickGuiTheme.buttonBg()); + FPSMaster.fontManager.s16.drawString("x", delX + 3, rY + 2, ClickGuiTheme.textPrimary().getRGB()); + + ScaledGuiScreen.PointerEvent delClick = screen.consumePressInBounds(delX, rY, 12, ROW_H, 0); + if (delClick != null) { + setting.removeEntry(i); + textFields.remove(i); + if (capturingRow == i) capturingRow = -1; + rebuildTextFields(); + return; + } + } + + // Capacity indicator + String capText = entries.size() + "/" + AutoTextSetting.MAX_CAPACITY; + FPSMaster.fontManager.s14.drawString(capText, x + width - 20 - 50, y + 2, ClickGuiTheme.textSecondary().getRGB()); + + this.height = 16 + entries.size() * (ROW_H + 3) + 4; + } + + // + button + float addY = y + 16 + entries.size() * (ROW_H + 3) + 4; + float addX = x + (width - 20 - 14) / 2; + boolean canAdd = entries.size() < AutoTextSetting.MAX_CAPACITY; + boolean addHover = Hover.is(addX, addY, 14, 14, (int) mouseX, (int) mouseY); + Color addBg = canAdd && addHover ? ClickGuiTheme.buttonHoverBg() : (canAdd ? ClickGuiTheme.buttonBg() : ClickGuiTheme.textFieldBg()); + Rects.rounded(Math.round(addX), Math.round(addY), 14, 14, CORNER, addBg); + FPSMaster.fontManager.s16.drawString("+", addX + 3, addY + 2, canAdd ? ClickGuiTheme.textPrimary().getRGB() : ClickGuiTheme.textSecondary().getRGB()); + + if (canAdd) { + ScaledGuiScreen.PointerEvent addClick = screen.consumePressInBounds(addX, addY, 14, 14, 0); + if (addClick != null) { + // 新条目不自动绑键:keyCode 保持 0(None),避免误占快捷栏键位, + // 由用户点击 Bind 按钮显式绑定。 + setting.addEntry(new AutoTextEntry(0, "")); + rebuildTextFields(); + return; + } + } + + // Duplicate warning + if (!duplicateWarning.isEmpty()) { + FPSMaster.fontManager.s14.drawString(duplicateWarning, x + 10, addY + 16, new Color(255, 80, 80).getRGB()); + this.height += 14; + } + + this.height += 24; + } + + @Override + public void keyTyped(char typedChar, int keyCode) { + if (capturingRow >= 0) { + ArrayList entries = setting.getValue(); + if (capturingRow < entries.size()) { + boolean duplicate = false; + for (int i = 0; i < entries.size(); i++) { + if (i != capturingRow && entries.get(i).keyCode == keyCode && keyCode != 0) { + duplicate = true; + break; + } + } + if (!duplicate) { + setting.editEntry(capturingRow, new AutoTextEntry(keyCode, entries.get(capturingRow).message)); + } + } + capturingRow = -1; + MainPanel.bindLock = ""; + return; + } + + for (TextField tf : textFields) { + tf.textboxKeyTyped(typedChar, keyCode); + } + } +} \ No newline at end of file diff --git a/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java b/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java index 00353e6d..15d01e06 100644 --- a/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java +++ b/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java @@ -36,6 +36,7 @@ public enum DragMode { // Initialize all components public void init() { addComponentSafely("FPSDisplayComponent", FPSDisplayComponent::new); + addComponentSafely("SaturationDisplayComponent", SaturationDisplayComponent::new); addComponentSafely("ArmorDisplayComponent", ArmorDisplayComponent::new); addComponentSafely("ScoreboardComponent", ScoreboardComponent::new); addComponentSafely("PotionDisplayComponent", PotionDisplayComponent::new); diff --git a/src/main/java/top/fpsmaster/ui/custom/impl/SaturationDisplayComponent.java b/src/main/java/top/fpsmaster/ui/custom/impl/SaturationDisplayComponent.java new file mode 100644 index 00000000..c9534d69 --- /dev/null +++ b/src/main/java/top/fpsmaster/ui/custom/impl/SaturationDisplayComponent.java @@ -0,0 +1,73 @@ +package top.fpsmaster.ui.custom.impl; + +import top.fpsmaster.features.impl.interfaces.SaturationDisplay; +import top.fpsmaster.ui.custom.Component; +import top.fpsmaster.ui.custom.Position; +import top.fpsmaster.utils.render.draw.Rects; + +import java.awt.Color; + +import static top.fpsmaster.utils.core.Utility.mc; + +/** + * 饱和度:一根 0–20 的条加精确数值。 + * + *

只画饱和度本身,不碰原版饥饿条——饱和度是原版隐藏的数值,把它显示出来不需要接管一个 + * 本来就正常渲染的 HUD 元素。 + */ +public class SaturationDisplayComponent extends Component { + private static final float MAX_SATURATION = 20f; + private static final float BAR_WIDTH = 40f; + private static final float BAR_HEIGHT = 4f; + private static final float GAP = 4f; + private static final float PADDING = 2f; + private static final Color TRACK = new Color(0, 0, 0, 120); + + public SaturationDisplayComponent() { + super(SaturationDisplay.class); + position = Position.RB; + // 默认落在原版饥饿条上方,视觉上仍然和饥饿相关,但不遮挡它。 + x = 0.02f; + y = 0.11f; + allowScale = true; + } + + @Override + public void measure() { + width = PADDING * 2f + BAR_WIDTH + GAP + getStringWidth(14, saturationText()); + height = 12f; + } + + @Override + public void draw(float x, float y) { + super.draw(x, y); + if (mc == null || mc.thePlayer == null) { + return; + } + + drawRect(x, y, width, height, mod.backgroundColor.getColor()); + + float saturation = mc.thePlayer.getFoodStats().getSaturationLevel(); + saturation = Math.max(0f, Math.min(MAX_SATURATION, saturation)); + + float barWidth = BAR_WIDTH * scale; + float barHeight = BAR_HEIGHT * scale; + float barX = x + PADDING * scale; + float barY = y + (height * scale - barHeight) / 2f; + + Rects.fill(barX, barY, barWidth, barHeight, TRACK); + if (saturation > 0f) { + Rects.fill(barX, barY, barWidth * (saturation / MAX_SATURATION), barHeight, + SaturationDisplay.barColor.getColor()); + } + + drawString(14, saturationText(), barX + barWidth + GAP * scale, y + 1f * scale, Color.WHITE.getRGB()); + } + + private String saturationText() { + if (mc == null || mc.thePlayer == null) { + return "0.0"; + } + return String.format(java.util.Locale.ROOT, "%.1f", mc.thePlayer.getFoodStats().getSaturationLevel()); + } +} diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 081f40b5..ecf85af9 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -210,6 +210,7 @@ betterchat.fontshadow=Font Shadow betterchat.betterfont=Clean Font betterchat.roundradius=Corner Radius betterchat.background=Show Background +betterchat.round=Rounded Corners betterchat.foldmessage=Fold Message betterchat.copymessage=Copy Message @@ -252,6 +253,7 @@ fpsdisplay.betterfont=Clean Font minimap=Minimap minimap.desc=Minimap overlay +minimap.fastrender.disable.title=Minimap is not compatible with Fast Render. Fast Render has been disabled. hotbar=Hotbar hotbar.desc=Enhanced hotbar visuals @@ -409,6 +411,10 @@ performance.textureresolution.sixteenth=Sixteenth performance.fastcollision=Fast Entity Collision performance.fasttextureupload=Fast Texture Upload performance.reusevisiblechunks=Reuse Visible Chunk List +performance.reuselevel=Reuse Visible Chunk Level +performance.reuselevel.conservative=Conservative +performance.reuselevel.balanced=Balanced +performance.reuselevel.aggressive=Aggressive performance.composedmodeltransform=Composed Model Transform performance.batchvanillafont=Batch Vanilla Font performance.fastglyphlookup=Fast Glyph Lookup @@ -422,6 +428,13 @@ performancehud.showdistribution=Show Frame Time Distribution performancehud.showmemory=Show Memory performancehud.showgc=Show Garbage Collection performancehud.colorbyhealth=Color By Health +performancehud.background=Show Background +performancehud.backgroundcolor=Background Color +performancehud.round=Rounded Corners +performancehud.roundradius=Corner Radius +performancehud.betterfont=Clean Font +performancehud.fontshadow=Font Shadow +performancehud.spacing=Spacing fullbright=Fullbright fullbright.desc=Keep brightness at max @@ -458,12 +471,14 @@ sprint=Sprint sprint.desc=Stay sprinting at all times sprint.togglesprint=Toggle Sprint sprint.betterfont=Clean Font +sprint.fontshadow=Font Shadow togglesneak=Toggle Sneak togglesneak.desc=Press the toggle key once to keep sneaking togglesneak.togglesneak=Toggle Sneak togglesneak.togglekey=Toggle Key togglesneak.betterfont=Clean Font +togglesneak.fontshadow=Font Shadow autogg=AutoGG autogg.desc=Automatically send a custom message after a game has ended. @@ -524,6 +539,12 @@ hitcolor.color=Color hideindicator=Hide Attack Indicator hideindicator.desc=Hides the attack cooldown bar +hideindicator.background=Show Background +hideindicator.backgroundcolor=Background Color +hideindicator.round=Rounded Corners +hideindicator.roundradius=Corner Radius +hideindicator.betterfont=Clean Font +hideindicator.fontshadow=Font Shadow lyricsdisplay=Lyrics Display lyricsdisplay.desc=Show synced lyrics @@ -591,6 +612,8 @@ blockindicator.roundradius=Round Radius blockindicator.backgroundcolor=Background Color blockindicator.panelcolor=Block Panel Color blockindicator.accentcolor=Accent Color +blockindicator.betterfont=Clean Font +blockindicator.fontshadow=Font Shadow playtime=Play Time playtime.desc=Track and display your play time @@ -660,6 +683,12 @@ nametags.background=Show Background taboverlay=Tab Overlay taboverlay.desc=Customize the tab list taboverlay.showping=Show Ping +taboverlay.background=Show Background +taboverlay.backgroundcolor=Background Color +taboverlay.round=Rounded Corners +taboverlay.roundradius=Corner Radius +taboverlay.betterfont=Clean Font +taboverlay.fontshadow=Font Shadow inventorydisplay=Inventory Display inventorydisplay.desc=Show inventory items on screen @@ -667,6 +696,8 @@ inventorydisplay.round=Rounded Corners inventorydisplay.backgroundcolor=Background Color inventorydisplay.roundradius=Corner Radius inventorydisplay.background=Show Background +inventorydisplay.betterfont=Clean Font +inventorydisplay.fontshadow=Font Shadow playerdisplay=Player Display playerdisplay.desc=Show nearby player info @@ -690,6 +721,10 @@ targetdisplay.espcolor=ESP Color targetdisplay.roundradius=Corner Radius targetdisplay.background=Show Background targetdisplay.omitname=Omit Long Names +targetdisplay.backgroundcolor=Background Color +targetdisplay.round=Rounded Corners +targetdisplay.betterfont=Clean Font +targetdisplay.fontshadow=Font Shadow itemcountdisplay=Item Count Display itemcountdisplay.desc=Quick show your items count in hud @@ -784,6 +819,8 @@ modslist.betterfont=Clean Font modslist.roundradius=Corner Radius modslist.background=Show Background modslist.spacing=Spacing +modslist.round=Rounded Corners +modslist.fontshadow=Font Shadow betterscreen=Enhanced UI betterscreen.desc=Improves vanilla UI visuals @@ -791,6 +828,11 @@ betterscreen.blur=Blur betterscreen.background=Enable Background betterscreen.backgroundanimation=Background Animation betterscreen.noflickering=No Flickering +betterscreen.backgroundcolor=Background Color +betterscreen.round=Rounded Corners +betterscreen.roundradius=Corner Radius +betterscreen.betterfont=Clean Font +betterscreen.fontshadow=Font Shadow clientcommand=Client Commands clientcommand.desc=Use commands to control modules @@ -863,6 +905,30 @@ betterfishingrod=Better Fishing Rod betterfishingrod.desc=Modify the fishing rod betterfishingrod.stringwidth=String Width +saturationdisplay=Saturation HUD +saturationdisplay.desc=Replace the vanilla food bar with a movable saturation display +saturationdisplay.background=Show Background +saturationdisplay.backgroundcolor=Background Color +saturationdisplay.round=Rounded Corners +saturationdisplay.roundradius=Corner Radius +saturationdisplay.betterfont=Better Font +saturationdisplay.fontshadow=Font Shadow + +customfog=Custom Fog +customfog.desc=Customize the color and distance of world fog +customfog.color=Fog Color +customfog.fogmode=Fog Mode +customfog.fogmode.linear=Linear +customfog.fogmode.exponential=Exponential +customfog.startdistance=Start Distance +customfog.enddistance=End Distance +customfog.affectwater=Affect Water Fog +customfog.affectlava=Affect Lava Fog +autotext=AutoText +autotext.desc=Send a saved chat message when a configured key is pressed +autotext.entries=Entries +autotext.empty=No AutoText entries. Click + to add one. + # Categories category.optimize=Performance diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index 81c065ad..9e149223 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -210,6 +210,7 @@ betterchat.fontshadow=字体阴影 betterchat.betterfont=更好的字体 betterchat.roundradius=圆角半径 betterchat.background=背景 +betterchat.round=背景圆角 betterchat.foldmessage=折叠消息 betterchat.copymessage=复制消息 @@ -252,6 +253,7 @@ fpsdisplay.betterfont=更好的字体 minimap=小地图 minimap.desc=小地图组件 +minimap.fastrender.disable.title=小地图与快速渲染不兼容,已为您自动关闭快速渲染。 hotbar=物品栏 hotbar.desc=更好的物品栏 @@ -409,6 +411,10 @@ performance.textureresolution.sixteenth=十六分之一 performance.fastcollision=快速实体碰撞 performance.fasttextureupload=快速纹理上传 performance.reusevisiblechunks=复用可见区块列表 +performance.reuselevel=复用可见区块级别 +performance.reuselevel.conservative=保守 +performance.reuselevel.balanced=均衡 +performance.reuselevel.aggressive=激进 performance.composedmodeltransform=合成模型变换 performance.batchvanillafont=原版字体批量绘制 performance.fastglyphlookup=字形查表加速 @@ -422,6 +428,13 @@ performancehud.showdistribution=显示帧时间分布 performancehud.showmemory=显示内存占用 performancehud.showgc=显示垃圾回收 performancehud.colorbyhealth=按健康度着色 +performancehud.background=背景 +performancehud.backgroundcolor=背景颜色 +performancehud.round=背景圆角 +performancehud.roundradius=圆角半径 +performancehud.betterfont=更好的字体 +performancehud.fontshadow=字体阴影 +performancehud.spacing=间距 fullbright=保持亮度 fullbright.desc=保持视野明亮 @@ -458,12 +471,14 @@ sprint=强制疾跑 sprint.desc=保持疾跑 sprint.togglesprint=切换疾跑 sprint.betterfont=更好的字体 +sprint.fontshadow=字体阴影 togglesneak=切换潜行 togglesneak.desc=按下切换键即可保持潜行 togglesneak.togglesneak=切换潜行 togglesneak.togglekey=切换按键 togglesneak.betterfont=更好的字体 +togglesneak.fontshadow=字体阴影 autogg=自动GG autogg.desc=游戏结束后自动地在发送你自定义的消息 @@ -523,6 +538,12 @@ hitcolor.color=颜色 hideindicator=隐藏攻击指示器 hideindicator.desc=隐藏攻击指示器 +hideindicator.background=背景 +hideindicator.backgroundcolor=背景颜色 +hideindicator.round=背景圆角 +hideindicator.roundradius=圆角半径 +hideindicator.betterfont=更好的字体 +hideindicator.fontshadow=字体阴影 lyricsdisplay=歌词显示 lyricsdisplay.desc=歌词显示 @@ -591,6 +612,8 @@ blockindicator.roundradius=圆角半径 blockindicator.backgroundcolor=背景颜色 blockindicator.panelcolor=方块面板颜色 blockindicator.accentcolor=强调色 +blockindicator.betterfont=更好的字体 +blockindicator.fontshadow=字体阴影 playtime=游玩时间 playtime.desc=统计并显示你的游玩时间 @@ -660,6 +683,12 @@ nametags.background=背景 taboverlay=Tab显示 taboverlay.desc=自定义Tab界面 taboverlay.showping=显示数字延迟 +taboverlay.background=背景 +taboverlay.backgroundcolor=背景颜色 +taboverlay.round=背景圆角 +taboverlay.roundradius=圆角半径 +taboverlay.betterfont=更好的字体 +taboverlay.fontshadow=字体阴影 inventorydisplay=物品栏显示 inventorydisplay.desc=显示物品栏内物品 @@ -667,6 +696,8 @@ inventorydisplay.round=背景圆角 inventorydisplay.backgroundcolor=背景颜色 inventorydisplay.roundradius=圆角半径 inventorydisplay.background=背景 +inventorydisplay.betterfont=更好的字体 +inventorydisplay.fontshadow=字体阴影 playerdisplay=玩家显示 playerdisplay.desc=显示附近玩家的信息 @@ -690,6 +721,10 @@ targetdisplay.espcolor=ESP颜色 targetdisplay.roundradius=圆角半径 targetdisplay.background=背景 targetdisplay.omitname=省略过长的名字 +targetdisplay.backgroundcolor=背景颜色 +targetdisplay.round=背景圆角 +targetdisplay.betterfont=更好的字体 +targetdisplay.fontshadow=字体阴影 itemcountdisplay=物品数量 itemcountdisplay.desc=便捷的展示你的物品数量 @@ -785,6 +820,8 @@ modslist.betterfont=更好的字体 modslist.roundradius=圆角半径 modslist.background=背景 modslist.spacing=间距 +modslist.round=背景圆角 +modslist.fontshadow=字体阴影 betterscreen=更好的界面 betterscreen.desc=让原版的部分界面看起来更好 @@ -792,6 +829,11 @@ betterscreen.blur=模糊 betterscreen.background=开启背景 betterscreen.backgroundanimation=背景动画 betterscreen.noflickering=防止闪烁 +betterscreen.backgroundcolor=背景颜色 +betterscreen.round=背景圆角 +betterscreen.roundradius=圆角半径 +betterscreen.betterfont=更好的字体 +betterscreen.fontshadow=字体阴影 clientcommand=客户端命令 clientcommand.desc=使用命令执行客户端功能 @@ -863,6 +905,30 @@ betterfishingrod=更好的鱼竿 betterfishingrod.desc=修改钓鱼竿 betterfishingrod.stringwidth=线宽度 +saturationdisplay=饱和度显示 +saturationdisplay.desc=用可自由移动的饱和度显示替代原版饥饿条 +saturationdisplay.background=背景 +saturationdisplay.backgroundcolor=背景颜色 +saturationdisplay.round=背景圆角 +saturationdisplay.roundradius=圆角半径 +saturationdisplay.betterfont=更好的字体 +saturationdisplay.fontshadow=字体阴影 + +customfog=自定义迷雾 +customfog.desc=自定义世界迷雾的颜色和距离 +customfog.color=迷雾颜色 +customfog.fogmode=迷雾模式 +customfog.fogmode.linear=线性 +customfog.fogmode.exponential=指数 +customfog.startdistance=起始距离 +customfog.enddistance=结束距离 +customfog.affectwater=影响水面迷雾 +customfog.affectlava=影响岩浆迷雾 +autotext=自动文本 +autotext.desc=按下设定的快捷键时发送保存的聊天消息 +autotext.entries=条目 +autotext.empty=暂无自动文本条目,点击 + 添加。 + # 类别 category.optimize=优化 diff --git a/src/main/resources/native/windows/x64/.gitkeep b/src/main/resources/native/windows/x64/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/native/windows/x86/.gitkeep b/src/main/resources/native/windows/x86/.gitkeep new file mode 100644 index 00000000..e69de29b