diff --git a/TODO.md b/TODO.md index b975d03..c733d76 100644 --- a/TODO.md +++ b/TODO.md @@ -114,6 +114,14 @@ boilerplate на класс), а в Rust/TypeScript тот же объём ло `releases/latest/download/lovisual.jar` в GitHub; в план сайта добавлены редактор тем в браузере, страница «Скачать» + ченджлог, публичные профили; лендинг — насыщенный (живой ClickGui/HUD, частицы), см. `frontend/PLAN.md`. + Интеграция мода 2026-09-27: первый шаг — URL бэкенда стал runtime-настройкой, + а не константой сборки. Новый `PlatformConfig` (`mod/src/main/java/dev/loki/lovisual/features/platform/`, + + `BackendEndpoint` нормализатор) хранит `backendBaseUrl` через тот же + `ConfigSerializer`, редактируется в ClickGui (категория UTILITY главных + настроек, рядом со `StyleConfig`). Значение по умолчанию — плейсхолдер + `https://api.lovisual.example` (реальный прод-домен ещё нигде в репо не + зафиксирован). HTTP-клиент и сама интеграция (auth/device-link/витрина) — + отдельная будущая задача. --- diff --git a/mod/README.md b/mod/README.md index 08649e4..93964f2 100644 --- a/mod/README.md +++ b/mod/README.md @@ -35,7 +35,7 @@ LoVisual is developed and maintained by [loki5512344](https://github.com/loki551 | Feature | Description | |---------|-------------| | Combat | ProjectilePuncher, Reach, RubberHand, SpearAssist, TPSSync, PvpCooldowns, NoFriendDamage, TargetESP, Predictions, DamageTint | -| Visuals | ESP, NameTags, Chams, Crosshair, FullBright, MotionBlur, NoRender, Freecam, Hitboxes, Light, ChinaHat, Trinkets, and more | +| Visuals | ESP, NameTags, Chams, Crosshair, FullBright, MotionBlur, NoRender, Freecam, Hitboxes, Light, ChinaHat, Trinkets, SpatialGui, and more | | Player | AutoTool, ClickPearl, FakeLag, NoDelay, LockSlot, AutoRespawn, DeathCoords, NoInteract | | Misc | BetterMinecraft, FakePlayer, HitSounds, NameProtect, MessageFilter, NoSound, DefineTarget | | HUD | Draggable & static elements: TargetHUD, ModuleList, Watermark, Reach, Armor, Potions, Keystrokes, Coords, Notifications, Scoreboard | @@ -90,7 +90,7 @@ LoVisual разрабатывается и поддерживается [loki551 | Возможность | Описание | |-------------|----------| | Combat | ProjectilePuncher, Reach, RubberHand, SpearAssist, TPSSync, PvpCooldowns, NoFriendDamage, TargetESP, Predictions, DamageTint | -| Visuals | ESP, NameTags, Chams, Crosshair, FullBright, MotionBlur, NoRender, Freecam, Hitboxes, Light, ChinaHat, Trinkets и другие | +| Visuals | ESP, NameTags, Chams, Crosshair, FullBright, MotionBlur, NoRender, Freecam, Hitboxes, Light, ChinaHat, Trinkets, SpatialGui и другие | | Player | AutoTool, ClickPearl, FakeLag, NoDelay, LockSlot, AutoRespawn, DeathCoords, NoInteract | | Misc | BetterMinecraft, FakePlayer, HitSounds, NameProtect, MessageFilter, NoSound, DefineTarget | | HUD | Перетаскиваемые и статические элементы: TargetHUD, ModuleList, Watermark, Reach, Armor, Potions, Keystrokes, Coords, Notifications, Scoreboard | @@ -141,6 +141,11 @@ LoVisual разрабатывается и поддерживается [loki551 GNU General Public License v3.0. See [LICENSE](LICENSE). +### Credits + +- The `SpatialGui` module (rendering the open screen as a 3D plane in the world) is adapted from + [Spatial GUI](https://github.com/tastytrash/Spatial-GUI) (MIT License). + --- ## TODO diff --git a/mod/TODO.md b/mod/TODO.md index 6b20e74..16087bd 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -292,7 +292,11 @@ и ключи i18n не менялись; package-private члены, ставшие кросс-пакетными, расширены до public/protected; svgMsdfTool-импорты починены после split svg. -### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) +### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) — ЗАВЕРШЕНА +> Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк +> (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53 +> пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон, +> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤200». > Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка: > Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные > классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`. diff --git a/mod/src/main/java/dev/loki/lovisual/config/common/CommonSettingSchemas.java b/mod/src/main/java/dev/loki/lovisual/config/common/CommonSettingSchemas.java index 2394a72..a843b87 100644 --- a/mod/src/main/java/dev/loki/lovisual/config/common/CommonSettingSchemas.java +++ b/mod/src/main/java/dev/loki/lovisual/config/common/CommonSettingSchemas.java @@ -3,7 +3,6 @@ package dev.loki.lovisual.config.common; import dev.loki.lovisual.config.common.impl.common.CombatPriority; import dev.loki.lovisual.config.common.impl.common.CombatProtocolHeuristicSources; import dev.loki.lovisual.config.common.impl.common.CommonKey; -import dev.loki.lovisual.config.common.impl.common.FakeLagFlushOn; import dev.loki.lovisual.config.common.impl.rotation.RotationMode; import dev.loki.lovisual.config.common.impl.rotation.RotationMovementCorrection; import dev.loki.lovisual.config.common.impl.rotation.RotationResetThreshold; @@ -16,7 +15,6 @@ import dev.loki.lovisual.config.common.CommonSettingSchema; import dev.loki.lovisual.config.common.impl.common.CombatPriority; import dev.loki.lovisual.config.common.impl.common.CombatProtocolHeuristicSources; import dev.loki.lovisual.config.common.impl.common.CommonKey; -import dev.loki.lovisual.config.common.impl.common.FakeLagFlushOn; import dev.loki.lovisual.config.common.impl.rotation.RotationMode; import dev.loki.lovisual.config.common.impl.rotation.RotationMovementCorrection; import dev.loki.lovisual.config.common.impl.rotation.RotationResetThreshold; @@ -131,13 +129,6 @@ public enum CommonSettingSchemas { public static final CommonSettingSchema PLAYER_USE_RELATIONS = key("player.use_relations"); public static final CommonSettingSchema TOTEM_POP_COUNTER = key("totem_pop.counter", "totem_pop_counter"); public static final CommonSettingSchema TOTEM_POP_RESET_SECONDS = key("totem_pop.reset_seconds", "totem_pop_reset_seconds"); - public static final CommonSettingSchema PLAYER_FAKELAG_MODE = key("player.fakelag.mode"); - public static final CommonSettingSchema PLAYER_FAKELAG_RANGE_MIN = key("player.fakelag.range_min", "min_range", "range_min"); - public static final CommonSettingSchema PLAYER_FAKELAG_RANGE_MAX = key("player.fakelag.range_max", "max_range", "range_max"); - public static final CommonSettingSchema PLAYER_FAKELAG_DELAY_MIN = key("player.fakelag.delay_min", "delay_min"); - public static final CommonSettingSchema PLAYER_FAKELAG_DELAY_MAX = key("player.fakelag.delay_max", "delay_max"); - public static final CommonSettingSchema PLAYER_FAKELAG_RECOIL_TIME = key("player.fakelag.recoil_time", "recoil_time"); - public static final FakeLagFlushOn PLAYER_FAKELAG_FLUSH_ON = new FakeLagFlushOn(); // Elytra target / fireworks public static final CommonSettingSchema ELYTRA_HITBOX_EXPAND = key("elytra.hitbox_expand"); public static final CommonSettingSchema ELYTRA_EXPAND_WHEN_READY = key("elytra.expand_when_ready"); diff --git a/mod/src/main/java/dev/loki/lovisual/config/common/impl/common/FakeLagFlushOn.java b/mod/src/main/java/dev/loki/lovisual/config/common/impl/common/FakeLagFlushOn.java deleted file mode 100644 index af49971..0000000 --- a/mod/src/main/java/dev/loki/lovisual/config/common/impl/common/FakeLagFlushOn.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.loki.lovisual.config.common.impl.common; - -import dev.loki.lovisual.config.common.CommonBooleanGroupSchema; - -import java.util.LinkedHashMap; -import java.util.Map; - -public final class FakeLagFlushOn implements CommonBooleanGroupSchema { - public static final String ENTITY_INTERACT = "entity_interact"; - public static final String BLOCK_INTERACT = "block_interact"; - public static final String ACTION = "action"; - - @Override - public String commonI18nKey() { - return "fakelag.flush_on"; - } - - @Override - public Map defaults() { - Map defaults = new LinkedHashMap<>(); - defaults.put(ENTITY_INTERACT, false); - defaults.put(BLOCK_INTERACT, false); - defaults.put(ACTION, false); - return defaults; - } -} diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java index 9cc869b..8b6244b 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java @@ -7,6 +7,7 @@ import dev.loki.lovisual.features.command.core.CommandContext; import dev.loki.lovisual.features.command.core.CommandInfo; import dev.loki.lovisual.features.command.util.CommandOutput; import dev.loki.lovisual.features.module.core.ModuleManager; +import dev.loki.lovisual.features.platform.config.ConfigShareLoader; import java.util.List; import java.util.Locale; @@ -14,7 +15,7 @@ import java.util.Locale; @CommandInfo( id = "config", aliases = {"cfg", "settings"}, - usage = "%config [save|load|path]", + usage = "%config [save|load [code]|path]", descriptionKey = "command.config.description" ) public final class ConfigCommand implements ClientCommand { @@ -28,6 +29,12 @@ public final class ConfigCommand implements ClientCommand { CommandOutput.success("Client config saved."); } case "load", "reload" -> { + if (ctx.arg(1) != null) { + // %config load : fetch a shared config from the backend + // (GET /configs/shared/{code}) instead of reloading local files. + ConfigShareLoader.load(ctx.arg(1)); + break; + } ConfigSerializer.loadAll(); ModuleManager.loadAllModuleConfigs(); CommandOutput.success("Client config reloaded."); diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java new file mode 100644 index 0000000..bae8b53 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java @@ -0,0 +1,140 @@ +package dev.loki.lovisual.features.command.impl.platform; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.features.command.core.CommandContext; +import dev.loki.lovisual.features.command.core.CommandInfo; +import dev.loki.lovisual.features.command.util.ClientCommand; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.PlatformSession; +import dev.loki.lovisual.features.platform.http.ApiResult; +import dev.loki.lovisual.features.platform.link.DeviceLinkFlow; + +import java.util.List; +import java.util.Locale; + +/** + * Links this client to a LoVisual platform account: {@code %link} starts the + * device-code flow (request a code, show it, poll until the site confirms + * it), {@code %link status} confirms the stored device token via + * {@code GET /me}, {@code %link unlink} clears it locally and tries to + * revoke the link server-side. Command-based only per this pass's scope; no + * ClickGui screen. + */ +@CommandInfo( + id = "link", + usage = "%link [status|unlink]", + descriptionKey = "command.link.description" +) +public final class LinkCommand implements ClientCommand { + + private static DeviceLinkFlow activeFlow; + + @Override + public boolean execute(CommandContext ctx) { + String action = ctx.arg(0) == null ? "" : ctx.arg(0).toLowerCase(Locale.ROOT); + switch (action) { + case "" -> startLink(); + case "status" -> checkStatus(); + case "unlink" -> unlink(); + case "cancel" -> cancelLink(); + default -> CommandOutput.warning("Usage: " + metadata().usage()); + } + return true; + } + + private void startLink() { + if (PlatformSession.get().isLinked()) { + CommandOutput.warning("Already linked. Use %link unlink first, or %link status to check."); + return; + } + if (activeFlow != null) { + CommandOutput.warning("A link request is already in progress. Use %link cancel to stop it."); + return; + } + CommandOutput.send("Requesting a device link code..."); + activeFlow = new DeviceLinkFlow(); + activeFlow.start(this::onFlowUpdate); + } + + private void onFlowUpdate(DeviceLinkFlow.Result result) { + switch (result.status()) { + case AWAITING_CONFIRMATION -> CommandOutput.success( + "Open the LoVisual site and enter this code: " + result.userCode()); + case LINKED -> { + activeFlow = null; + CommandOutput.success("Device linked."); + checkStatus(); + } + case FAILED -> { + activeFlow = null; + CommandOutput.error("Link failed: " + result.message()); + } + } + } + + private void cancelLink() { + if (activeFlow == null) { + CommandOutput.warning("No link request in progress."); + return; + } + activeFlow.cancel(); + activeFlow = null; + CommandOutput.send("Link request cancelled."); + } + + private void checkStatus() { + if (!PlatformSession.get().isLinked()) { + CommandOutput.send("Not linked. Use %link to start."); + return; + } + DeviceLinkFlow.fetchMe().thenAccept(result -> { + if (dispatchOnClientThread(() -> reportMe(result))) return; + reportMe(result); + }); + } + + private void reportMe(ApiResult result) { + if (!result.success() || result.asObject() == null) { + CommandOutput.error("Linked, but /me failed: " + result.error()); + return; + } + JsonObject me = result.asObject(); + String nick = me.has("display_nick") ? me.get("display_nick").getAsString() : "?"; + String email = me.has("email") ? me.get("email").getAsString() : "?"; + CommandOutput.success("Linked as " + nick + " (" + email + ")"); + } + + private void unlink() { + if (!PlatformSession.get().isLinked()) { + CommandOutput.warning("Not linked."); + return; + } + // Minimal per scope: clear the local token only. Revoking the link + // server-side needs its `id` from GET /device/links, which this pass + // does not fetch/track (kept out of scope to avoid a second async + // round trip just for %link unlink); a future pass can look the link + // up by matching last_seen or add a "revoke current device" endpoint. + PlatformSession.get().clear(); + CommandOutput.success("Unlinked locally."); + } + + private static boolean dispatchOnClientThread(Runnable runnable) { + net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); + if (mc == null) { + return false; + } + if (mc.isSameThread()) { + runnable.run(); + return true; + } + mc.execute(runnable); + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex != 1) return List.of(); + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return List.of("status", "unlink", "cancel").stream().filter(v -> v.startsWith(lower)).toList(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java index 9d0c60a..9ea7e79 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java @@ -4,6 +4,7 @@ import dev.loki.lovisual.config.definition.SettingOwner; import dev.loki.lovisual.config.i18n.MainConfig; import dev.loki.lovisual.config.definition.SettingDef; import dev.loki.lovisual.config.style.StyleConfig; +import dev.loki.lovisual.features.platform.PlatformConfig; import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; @@ -290,6 +291,7 @@ public final class MainSettingsComponent { settingsByCategory.put(Category.SECURITY, buildSettings(config.getSecuritySettingDefs(), config)); List utility = buildSettings(config.getUtilitySettingDefs(), config); utility.addAll(buildSettings(StyleConfig.get().getSettingDefs(), StyleConfig.get())); + utility.addAll(buildSettings(PlatformConfig.get().getSettingDefs(), PlatformConfig.get())); settingsByCategory.put(Category.UTILITY, utility); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerConstants.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerConstants.java index fe33060..45d5dbd 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerConstants.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerConstants.java @@ -22,6 +22,9 @@ final class MediaPlayerConstants { static final float BASE_TITLE_SCROLL_GAP = 14f; static final float BASE_TITLE_FADE = 12f; static final float TITLE_SCROLL_PAUSE_SEC = 0.8f; + static final float BASE_ARTIST_SCROLL_SPEED = 16f; + static final float BASE_ARTIST_SCROLL_GAP = 14f; + static final float ARTIST_SCROLL_PAUSE_SEC = 1.0f; static final float BASE_CONTENT_W = 170f; static final int ICON_PAUSE = 0xEA02; static final int ICON_PLAY = 0xEA03; @@ -63,4 +66,19 @@ final class MediaPlayerConstants { static float clamp01(float v) { return Math.max(0f, Math.min(1f, v)); } + + /** + * Pure marquee-scroll offset calculator, shared by the title and artist lines: holds at + * offset 0 for {@code pauseSec}, then slides left at {@code speed} px/s over one + * {@code cycleDistance}, before the caller's outer {@code now % cycleTime} wraps it back. + * Extracted so the scroll math is unit-testable without a running renderer. + */ + static float scrollOffset(float nowSeconds, float cycleDistance, float speed, float pauseSec) { + if (speed <= 0f) return 0f; + float cycleTime = pauseSec + (cycleDistance / speed); + if (cycleTime <= 0f) return 0f; + float t = nowSeconds % cycleTime; + if (t <= pauseSec) return 0f; + return -(t - pauseSec) * speed; + } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerRenderHelper.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerRenderHelper.java index ec5cedb..3257633 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerRenderHelper.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerRenderHelper.java @@ -69,7 +69,9 @@ final class MediaPlayerRenderHelper { float maxOwnerW = Math.max(0f, contentW - timeW - buttonGap); String ownerText = hasSession ? MediaPlayerTextHelper.trimText(metaRenderer, owner, maxOwnerW, metaScale) : ""; String titleText = hasSession ? title : ""; - String artistText = hasSession ? MediaPlayerTextHelper.trimText(artistRenderer, artist, contentW, artistScale) : ""; + // Full artist text is kept (not pre-trimmed): overflow is handled by the marquee scroll + // below, matching the title row, instead of silently ellipsizing long artist names. + String artistText = hasSession ? artist : ""; titleRenderer.begin(titleScale, false, false); float titleW = hasSession ? (float) titleRenderer.getWidth(titleText, false) : 0f; @@ -77,7 +79,7 @@ final class MediaPlayerRenderHelper { titleRenderer.end(); artistRenderer.begin(artistScale, false, false); - if (hasSession) artistRenderer.getWidth(artistText, false); + float artistW = hasSession ? (float) artistRenderer.getWidth(artistText, false) : 0f; float artistH = (float) artistRenderer.getHeight(false); artistRenderer.end(); @@ -143,7 +145,7 @@ final class MediaPlayerRenderHelper { MediaPlayerTextHelper.drawArtwork(p, renderer, snapshot, drawX, drawY, artX, artY, artSize, radius, baseScale, scale); MediaPlayerTextHelper.drawTexts(p, renderer, titleRenderer, artistRenderer, metaRenderer, iconRenderer, titleText, artistText, timeText, ownerText, ownerW, - drawX, drawY, textX, contentY, artistY, barY, bottomY, contentW, titleW, titleH, titleScale, artistScale, metaScale, iconScale, + drawX, drawY, textX, contentY, artistY, barY, bottomY, contentW, titleW, artistW, titleH, titleScale, artistScale, metaScale, iconScale, barH, baseScale, scale, snapshot, btnSizeBase, buttonGap, metaH, showShuffle, showRepeat); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerTextHelper.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerTextHelper.java index 9b19346..39d5302 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerTextHelper.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/mediaplayer/MediaPlayerTextHelper.java @@ -13,22 +13,22 @@ final class MediaPlayerTextHelper { private MediaPlayerTextHelper() {} - static void drawScrollingTitle(MediaPlayer p, TextRenderer renderer, String text, float x, float y, float fullWidth, float viewWidth, float scale, float baseScale, float alpha) { - float speed = MediaPlayerConstants.BASE_TITLE_SCROLL_SPEED * baseScale; - float gap = MediaPlayerConstants.BASE_TITLE_SCROLL_GAP * baseScale; + /** + * Marquee-scrolls {@code text} left within {@code viewWidth} when it overflows, fading at the + * edges. Shared by the title and artist lines (each passes its own speed/gap/pause so the + * artist line drifts slower and pauses longer than the title). The offset math itself lives in + * {@link MediaPlayerConstants#scrollOffset} so it can be unit tested without a renderer. + */ + static void drawScrollingText(MediaPlayer p, TextRenderer renderer, String text, float x, float y, float fullWidth, float viewWidth, float scale, float baseScale, float alpha, float speedBase, float gapBase, float pauseSec, float fadeBase, int baseColor) { + float speed = speedBase * baseScale; + float gap = gapBase * baseScale; if (speed <= 0f) return; float cycleDistance = fullWidth + gap; - float cycleTime = MediaPlayerConstants.TITLE_SCROLL_PAUSE_SEC + (cycleDistance / speed); - if (cycleTime <= 0f) return; float now = Util.getMillis() / 1000f; - float t = now % cycleTime; - float offset = 0f; - if (t > MediaPlayerConstants.TITLE_SCROLL_PAUSE_SEC) { - offset = -(t - MediaPlayerConstants.TITLE_SCROLL_PAUSE_SEC) * speed; - } - int color = HudRenderUtil.scaleAlpha(p.uiText, alpha); + float offset = MediaPlayerConstants.scrollOffset(now, cycleDistance, speed, pauseSec); + int color = HudRenderUtil.scaleAlpha(baseColor, alpha); RenderColor renderColor = new RenderColor(color); - float fade = Math.min(viewWidth * 0.25f, MediaPlayerConstants.BASE_TITLE_FADE * baseScale); + float fade = Math.min(viewWidth * 0.25f, fadeBase * baseScale); renderer.begin(scale, false, false); try { renderer.renderHorizontalFadeClipped(text, x + offset, y, renderColor, x, x + viewWidth, fade, fade, false); @@ -109,22 +109,31 @@ final class MediaPlayerTextHelper { static void drawTexts(MediaPlayer p, Renderer2D renderer, TextRenderer titleRenderer, TextRenderer artistRenderer, TextRenderer metaRenderer, TextRenderer iconRenderer, String titleText, String artistText, String timeText, String ownerText, float ownerW, float drawX, float drawY, float textX, float contentY, float artistY, float barY, float bottomY, - float contentW, float titleW, float titleH, float titleScale, float artistScale, float metaScale, float iconScale, + float contentW, float titleW, float artistW, float titleH, float titleScale, float artistScale, float metaScale, float iconScale, float barH, float baseScale, float scale, MediaSessionService.Snapshot snapshot, float btnSizeBase, float buttonGap, float metaH, boolean showShuffle, boolean showRepeat) { float titleRenderX = drawX + textX * scale; float titleRenderY = drawY + contentY * scale; - float titleRenderW = contentW * scale; + float rowRenderW = contentW * scale; boolean titleScroll = titleW > contentW * 1.01f; if (titleScroll) { - drawScrollingTitle(p, titleRenderer, titleText, titleRenderX, titleRenderY, titleW * scale, titleRenderW, titleScale * scale, baseScale, scale); + drawScrollingText(p, titleRenderer, titleText, titleRenderX, titleRenderY, titleW * scale, rowRenderW, titleScale * scale, baseScale, scale, + MediaPlayerConstants.BASE_TITLE_SCROLL_SPEED, MediaPlayerConstants.BASE_TITLE_SCROLL_GAP, MediaPlayerConstants.TITLE_SCROLL_PAUSE_SEC, MediaPlayerConstants.BASE_TITLE_FADE, p.uiText); } else { titleRenderer.begin(titleScale * scale, false, false); titleRenderer.render(titleText, titleRenderX, titleRenderY, new RenderColor(HudRenderUtil.scaleAlpha(p.uiText, scale)), false); titleRenderer.end(); } - artistRenderer.begin(artistScale * scale, false, false); - artistRenderer.render(artistText, drawX + textX * scale, drawY + artistY * scale, new RenderColor(HudRenderUtil.scaleAlpha(p.uiTextMuted, scale)), false); - artistRenderer.end(); + float artistRenderX = drawX + textX * scale; + float artistRenderY = drawY + artistY * scale; + boolean artistScroll = artistW > contentW * 1.01f; + if (artistScroll) { + drawScrollingText(p, artistRenderer, artistText, artistRenderX, artistRenderY, artistW * scale, rowRenderW, artistScale * scale, baseScale, scale, + MediaPlayerConstants.BASE_ARTIST_SCROLL_SPEED, MediaPlayerConstants.BASE_ARTIST_SCROLL_GAP, MediaPlayerConstants.ARTIST_SCROLL_PAUSE_SEC, MediaPlayerConstants.BASE_TITLE_FADE, p.uiTextMuted); + } else { + artistRenderer.begin(artistScale * scale, false, false); + artistRenderer.render(artistText, artistRenderX, artistRenderY, new RenderColor(HudRenderUtil.scaleAlpha(p.uiTextMuted, scale)), false); + artistRenderer.end(); + } float barX = drawX + textX * scale; float barW = contentW * scale; float barRadius = Math.max(1f, barH * 0.5f) * scale; diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/panels/keybinds/KeybindsData.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/panels/keybinds/KeybindsData.java index be18f96..464da3c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/panels/keybinds/KeybindsData.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/panels/keybinds/KeybindsData.java @@ -23,6 +23,7 @@ final class KeybindsData { case MOVEMENT -> "accessibility"; case PLAYER -> "user"; case VISUALS -> "tree-pine"; + case ACCESSORIES -> "shirt"; default -> "ellipsis"; }; } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/core/ModuleCategory.java b/mod/src/main/java/dev/loki/lovisual/features/module/core/ModuleCategory.java index 1268590..130d8ce 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/core/ModuleCategory.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/core/ModuleCategory.java @@ -5,5 +5,6 @@ public enum ModuleCategory { MOVEMENT, PLAYER, VISUALS, + ACCESSORIES, MISC } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/player/FakeLag.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/player/FakeLag.java deleted file mode 100644 index 6c78b68..0000000 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/player/FakeLag.java +++ /dev/null @@ -1,144 +0,0 @@ -package dev.loki.lovisual.features.module.modules.player; - -import dev.loki.lovisual.config.common.CommonSettingSchemas; -import dev.loki.lovisual.config.common.impl.common.FakeLagFlushOn; -import dev.loki.lovisual.config.values.primitive.BooleanMapValue; -import dev.loki.lovisual.config.values.mode.EnumValue; -import dev.loki.lovisual.config.values.primitive.NumberValue; -import dev.loki.lovisual.features.module.core.Module; -import dev.loki.lovisual.features.module.core.ModuleCategory; -import dev.loki.lovisual.features.module.core.ModuleInfo; -import dev.loki.lovisual.util.network.control.FakeLagController; - -import java.util.EnumSet; -import dev.loki.lovisual.util.network.control.BlinkManager; - -/** - * FakeLag module front-end for the shared BlinkManager-backed controller. - */ -//todo Description -@ModuleInfo( - id = "fakelag", - displayName = "FakeLag", - category = ModuleCategory.PLAYER -) -public final class FakeLag extends Module { - private final NumberValue minRange = numCommon( - "fakeLagMinRange", - "min_range", - CommonSettingSchemas.PLAYER_FAKELAG_RANGE_MIN, - 2.0f, - 0.0f, - 10.0f - ); - - private final NumberValue maxRange = numCommon( - "fakeLagMaxRange", - "max_range", - CommonSettingSchemas.PLAYER_FAKELAG_RANGE_MAX, - 5.0f, - 0.0f, - 10.0f - ); - - private final NumberValue minDelay = numCommon( - "fakeLagMinDelay", - "delay_min", - CommonSettingSchemas.PLAYER_FAKELAG_DELAY_MIN, - 300, - 0, - 1000 - ); - - private final NumberValue maxDelay = numCommon( - "fakeLagMaxDelay", - "delay_max", - CommonSettingSchemas.PLAYER_FAKELAG_DELAY_MAX, - 600, - 0, - 1000 - ); - - private final NumberValue recoilTime = numCommon( - "fakeLagRecoilTime", - "recoil_time", - CommonSettingSchemas.PLAYER_FAKELAG_RECOIL_TIME, - 250, - 0, - 1000 - ); - - private final EnumValue mode = enumCommon( - "fakeLagMode", - "mode", - CommonSettingSchemas.PLAYER_FAKELAG_MODE, - FakeLagController.Mode.DYNAMIC, - FakeLagController.Mode.class - ); - - private final BooleanMapValue flushOn = groupCommon( - "fakeLagFlushOn", - "flush_on", - CommonSettingSchemas.PLAYER_FAKELAG_FLUSH_ON - ); - - private FakeLagController.Config appliedConfig; - - @Override - public void onEnable() { - syncControllerConfig(); - FakeLagController.INSTANCE.setEnabled(true); - } - - @Override - public void onDisable() { - FakeLagController.INSTANCE.setEnabled(false); - appliedConfig = null; - } - - @Override - public void onTick() { - if (!isEnabled()) { - return; - } - syncControllerConfig(); - } - - private void syncControllerConfig() { - FakeLagController.Config config = buildConfig(); - if (!config.equals(appliedConfig) || !config.equals(FakeLagController.INSTANCE.getConfig())) { - FakeLagController.INSTANCE.configure(config); - appliedConfig = config; - } - } - - private FakeLagController.Config buildConfig() { - float min = minRange.get(); - float max = maxRange.get(); - int minMs = minDelay.get(); - int maxMs = maxDelay.get(); - return new FakeLagController.Config( - Math.min(min, max), - Math.max(min, max), - Math.min(minMs, maxMs), - Math.max(minMs, maxMs), - recoilTime.get(), - mode.get(), - selectedFlushOn() - ); - } - - private EnumSet selectedFlushOn() { - EnumSet selected = EnumSet.noneOf(FakeLagController.FlushOn.class); - if (flushOn.get(FakeLagFlushOn.ENTITY_INTERACT)) { - selected.add(FakeLagController.FlushOn.ENTITY_INTERACT); - } - if (flushOn.get(FakeLagFlushOn.BLOCK_INTERACT)) { - selected.add(FakeLagController.FlushOn.BLOCK_INTERACT); - } - if (flushOn.get(FakeLagFlushOn.ACTION)) { - selected.add(FakeLagController.FlushOn.ACTION); - } - return selected; - } -} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/ESP.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/ESP.java index a4267be..0cee103 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/ESP.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/ESP.java @@ -84,7 +84,7 @@ public class ESP extends Module { final BooleanValue renderSelf = bool("espRenderSelf", "render_self", false); final BooleanValue onlyVisible = - bool("espOnlyVisible", "only_visible", true); + bool("espOnlyVisible", "only_visible", false); final BooleanValue skipInvisible = bool("espSkipInvisible", "skip_invisible", true); final BooleanValue onlyArmored = diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/overlay/BlockESP.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/overlay/BlockESP.java deleted file mode 100644 index 7504911..0000000 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/overlay/BlockESP.java +++ /dev/null @@ -1,322 +0,0 @@ -package dev.loki.lovisual.features.module.modules.visuals.esp.overlay; - -import net.minecraft.client.Minecraft; -import net.minecraft.core.BlockPos; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.level.material.MapColor; -import net.minecraft.world.phys.AABB; -import dev.loki.lovisual.config.values.mode.EnumValue; -import dev.loki.lovisual.config.values.collection.ItemIdSetValue; -import dev.loki.lovisual.config.values.primitive.NumberValue; -import dev.loki.lovisual.config.values.color.RGBAColorValue; -import dev.loki.lovisual.features.gui.clickgui.settings.text.list.TextListSetting; -import dev.loki.lovisual.features.module.core.Module; -import dev.loki.lovisual.features.module.core.ModuleCategory; -import dev.loki.lovisual.features.module.core.ModuleInfo; -import dev.loki.lovisual.features.module.phase.WorldPhase; -import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; -import dev.loki.lovisual.render.engine.renderer.Renderer3D; -import dev.loki.lovisual.util.time.Timer; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; - -//todo Description -@ModuleInfo( - id = "blockesp", - displayName = "BlockESP", - category = ModuleCategory.VISUALS, - description = "Подсвечивает выбранные блоки сквозь стены" -) -public final class BlockESP extends Module { - - private final ItemIdSetValue blockList = - itemList("block_list", TextListSetting.PickerMode.BLOCKS); - - private final NumberValue range = - num("range", 64, 8, 160); - - private final NumberValue scanInterval = - num("scan_interval", 1.0f, 0.5f, 10.0f); - - private final RGBAColorValue fillColor = - color("fill_color", "#4000FFC8"); - - private final RGBAColorValue fillColor2 = - visibleWhen(color("fill_color2", "#40FFAA00"), this::usesSecondaryColor); - - private final EnumValue colorMode = - enumMode("color_mode", ColorMode.PER_BLOCK, ColorMode.values()); - - private final NumberValue colorSpeed = - visibleWhen(num("color_speed", 18, 2, 54), () -> colorMode.get() != ColorMode.PER_BLOCK && colorMode.get() != ColorMode.STATIC); - - private final Timer scanTimer = new Timer(); - private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { - Thread thread = new Thread(r, "BlockESP-Scanner"); - thread.setDaemon(true); - return thread; - }); - private final AtomicBoolean scanning = new AtomicBoolean(false); - private final AtomicBoolean scanInProgress = new AtomicBoolean(false); - private volatile List foundBlocks = new ArrayList<>(); - private int nearbyTickCounter = 0; - - public BlockESP() { - } - - @Override - public void onEnable() { - scanTimer.setMs(0); - scanning.set(true); - } - - @Override - public void onDisable() { - scanning.set(false); - foundBlocks = new ArrayList<>(); - } - - @Override - public void onTick() { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null || mc.player == null) return; - - if (scanTimer.passedS(scanInterval.get())) { - scanTimer.reset(); - requestScan(); - } - // Быстрое обновление рядом (камень→спавнер при подходе) — 4 тика ≈0.2с, радиус 16 - if (++nearbyTickCounter % 4 == 0) { - updateNearby(); - } - } - - @Override - public WorldPhase getWorldPhase() { - return WorldPhase.BEFORE_TRANSLUCENT; - } - - @Override - public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { - Minecraft mc = Minecraft.getInstance(); - if (!isEnabled() || mc.level == null || mc.player == null) return; - if (blockList.get().isEmpty()) return; - - List snapshot = foundBlocks; - for (BlockPos pos : snapshot) { - BlockState state = mc.level.getBlockState(pos); - if (state.isAir()) continue; - if (!blockList.get().contains(blockId(state.getBlock()))) continue; - - int argb = resolveColor(state.getBlock(), pos); - addFilledBox(renderer, new AABB(pos), argb); - } - } - - private void requestScan() { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null || mc.player == null) return; - if (!scanning.get()) return; - if (executor.isShutdown()) return; - if (!scanInProgress.compareAndSet(false, true)) return; - - executor.submit(() -> { - try { - scan(); - } finally { - scanInProgress.set(false); - } - }); - } - - private void scan() { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null || mc.player == null) return; - - List result = new ArrayList<>(); - BlockPos center = mc.player.blockPosition(); - int r = range.get(); - - int minX = center.getX() - r; - int maxX = center.getX() + r; - int minY = Math.max(mc.level.getMinY(), center.getY() - r); - int maxY = Math.min(mc.level.getMaxY(), center.getY() + r); - int minZ = center.getZ() - r; - int maxZ = center.getZ() + r; - - int minChunkX = minX >> 4; - int maxChunkX = maxX >> 4; - int minChunkZ = minZ >> 4; - int maxChunkZ = maxZ >> 4; - - for (int cx = minChunkX; cx <= maxChunkX; cx++) { - for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { - if (!scanning.get()) return; - if (!mc.level.hasChunk(cx, cz)) continue; - - int chunkMinX = Math.max(minX, cx << 4); - int chunkMaxX = Math.min(maxX, (cx << 4) + 15); - int chunkMinZ = Math.max(minZ, cz << 4); - int chunkMaxZ = Math.min(maxZ, (cz << 4) + 15); - - for (int x = chunkMinX; x <= chunkMaxX; x++) { - for (int y = minY; y <= maxY; y++) { - for (int z = chunkMinZ; z <= chunkMaxZ; z++) { - if (!scanning.get()) return; - - BlockPos pos = new BlockPos(x, y, z); - BlockState state = mc.level.getBlockState(pos); - if (state.isAir()) continue; - - if (blockList.get().contains(blockId(state.getBlock()))) { - result.add(pos.immutable()); - } - } - } - } - } - } - - foundBlocks = result; - } - - private void updateNearby() { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null || mc.player == null) return; - if (blockList.get().isEmpty()) return; - BlockPos center = mc.player.blockPosition(); - int nearbyR = Math.min(16, range.get()); - int minX = center.getX() - nearbyR; - int maxX = center.getX() + nearbyR; - int minY = Math.max(mc.level.getMinY(), center.getY() - nearbyR); - int maxY = Math.min(mc.level.getMaxY(), center.getY() + nearbyR); - int minZ = center.getZ() - nearbyR; - int maxZ = center.getZ() + nearbyR; - // snapshot для быстрой проверки наличия - List snapshot = foundBlocks; - java.util.HashSet set = new java.util.HashSet<>(snapshot); - boolean added = false; - List toAdd = new ArrayList<>(); - for (int x = minX; x <= maxX; x++) { - for (int y = minY; y <= maxY; y++) { - for (int z = minZ; z <= maxZ; z++) { - BlockPos pos = new BlockPos(x, y, z); - if (set.contains(pos)) continue; - BlockState state = mc.level.getBlockState(pos); - if (state.isAir()) continue; - if (blockList.get().contains(blockId(state.getBlock()))) { - toAdd.add(pos.immutable()); - added = true; - } - } - } - } - if (added) { - List updated = new ArrayList<>(snapshot.size() + toAdd.size()); - updated.addAll(snapshot); - updated.addAll(toAdd); - foundBlocks = updated; - } - } - - private String blockId(Block block) { - return BuiltInRegistries.BLOCK.getKey(block).toString(); - } - - private int resolveColor(Block block, BlockPos pos) { - if (colorMode.get() == ColorMode.PER_BLOCK) { - MapColor mapColor = block.defaultMapColor(); - return withAlpha(mapColor.col, alpha(fillColor.getArgb())); - } - - return AnimatedRenderColors.resolve( - animatedColorMode(), - colorSpeed.get(), - spatialIndex(pos), - fillColor.getArgb(), - fillColor2.getArgb(), - true - ); - } - - private AnimatedRenderColors.Mode animatedColorMode() { - return switch (colorMode.get()) { - case PER_BLOCK, STATIC -> AnimatedRenderColors.Mode.STATIC; - case RAINBOW -> AnimatedRenderColors.Mode.RAINBOW; - case FADE -> AnimatedRenderColors.Mode.FADE; - }; - } - - private boolean usesSecondaryColor() { - return AnimatedRenderColors.usesSecondary(animatedColorMode()); - } - - private int spatialIndex(BlockPos pos) { - return Math.floorMod(pos.getX() * 13 + pos.getY() * 7 + pos.getZ() * 17, 3600); - } - - private static void addFilledBox(Renderer3D renderer, AABB box, int argb) { - int a = alpha(argb); - int r = red(argb); - int g = green(argb); - int b = blue(argb); - - double minX = box.minX; - double minY = box.minY; - double minZ = box.minZ; - double maxX = box.maxX; - double maxY = box.maxY; - double maxZ = box.maxZ; - - renderer.quad(minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a); - renderer.quad(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ, r, g, b, a); - renderer.quad(minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a); - renderer.quad(minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ, r, g, b, a); - renderer.quad(maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ, r, g, b, a); - renderer.quad(minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a); - } - - private static int withAlpha(int argb, int alpha) { - return (alpha << 24) | (argb & 0x00FFFFFF); - } - - private static int red(int argb) { - return (argb >>> 16) & 255; - } - - private static int green(int argb) { - return (argb >>> 8) & 255; - } - - private static int blue(int argb) { - return argb & 255; - } - - private static int alpha(int argb) { - return (argb >>> 24) & 255; - } - - public enum ColorMode implements EnumValue.IdProvider { - PER_BLOCK("per_block"), - STATIC("static"), - RAINBOW("rainbow"), - FADE("fade"); - - private final String id; - - ColorMode(String id) { - this.id = id; - } - - @Override - public String id() { - return id; - } - } -} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/overlay/InvisEsp.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/overlay/InvisEsp.java deleted file mode 100644 index a68d5c1..0000000 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/esp/overlay/InvisEsp.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.loki.lovisual.features.module.modules.visuals.esp.overlay; - -import dev.loki.lovisual.config.values.primitive.NumberValue; -import dev.loki.lovisual.features.module.core.Module; -import dev.loki.lovisual.features.module.core.ModuleCategory; -import dev.loki.lovisual.features.module.core.ModuleInfo; - -/** - * Делает невидимых игроков видимыми: модель рендерится полупрозрачной, - * сила прозрачности регулируется слайдером. - */ -@ModuleInfo( - id = "invisesp", - displayName = "InvisESP", - aliases = {"invis"}, - category = ModuleCategory.VISUALS, - description = "module.invisesp.description" -) -public final class InvisEsp extends Module { - - private final NumberValue opacity = - num("invisesp_opacity", "opacity", 0.5f, 0.1f, 1.0f); - - { - setDefaultBind("L"); - } - - public float getOpacity() { - return opacity.get(); - } - - /** Прозрачный рендер имеет смысл только ниже полной непрозрачности. */ - public boolean isTranslucentReveal() { - return opacity.get() < 0.99f; - } -} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/lrc/LrcParser.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/lrc/LrcParser.java index c249262..ddba1f0 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/lrc/LrcParser.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/lrc/LrcParser.java @@ -56,4 +56,28 @@ public final class LrcParser { Collections.sort(lines); return lines; } + + /** + * Index of the lyric line active at {@code timeMs} within a list already sorted by + * {@link LyricLine#timestampMs()} (as returned by {@link #parse}), or {@code -1} before the + * first line / for an empty list. Plain data lookup with no playback-state dependency, so any + * consumer (the 3D lyrics module, or in future a compact HUD display) can reuse it without + * depending on {@code LyricPlayback}'s module-coupled state machine. + */ + public static int activeLineIndex(List lines, long timeMs) { + if (lines == null || lines.isEmpty()) return -1; + int lo = 0; + int hi = lines.size() - 1; + int result = -1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + if (lines.get(mid).timestampMs() <= timeMs) { + result = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return result; + } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/spatialgui/SpatialGui.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/spatialgui/SpatialGui.java new file mode 100644 index 0000000..8c54906 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/spatialgui/SpatialGui.java @@ -0,0 +1,292 @@ +/* + * Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License. + * Copyright (c) tastytrash and Spatial GUI contributors. + * + * This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms + * for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed + * portions ported from Spatial GUI remain available under the MIT terms above. + */ +package dev.loki.lovisual.features.module.modules.visuals.spatialgui; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.FilterMode; +import com.mojang.blaze3d.textures.GpuSampler; +import com.mojang.blaze3d.textures.GpuTextureView; +import dev.loki.lovisual.config.values.mode.ModeValue; +import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.features.module.core.ModuleCategory; +import dev.loki.lovisual.features.module.core.ModuleInfo; +import dev.loki.lovisual.features.module.phase.WorldPhase; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.spatialgui.SpatialGuiCapture; +import dev.loki.lovisual.render.spatialgui.SpatialGuiPlane; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import net.minecraft.client.Camera; +import net.minecraft.client.CameraType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Options; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import org.joml.Vector3f; +import org.joml.Vector3fc; + +/** + * Renders the currently open {@link Screen} as a plane floating in the 3D world instead of a flat HUD overlay, + * porting the core idea of Spatial GUI (https://github.com/tastytrash/Spatial-GUI, MIT) to LoVisual's own + * module/config system and its deferred GUI-render pipeline. + *

+ * The actual off-screen capture happens in {@code GuiRendererCaptureMixin} (which redirects the vanilla + * {@code GuiRenderer.draw()} pass into {@link SpatialGuiCapture} instead of the main render target while this + * module is active), and mouse routing happens in {@code MouseHandlerSpatialGuiMixin} (which reprojects the + * real cursor position onto the plane published every frame via {@link SpatialGuiPlane}). + */ +@ModuleInfo( + id = "spatialgui", + displayName = "SpatialGui", + category = ModuleCategory.VISUALS, + description = "Renders the open screen as a 3D plane in the world instead of a flat overlay." +) +public class SpatialGui extends Module { + + private static SpatialGui instance; + + private final BooleanValue containerOnly = bool("spatialGuiContainerOnly", "container_only", true); + private final BooleanValue thirdPerson = bool("spatialGuiThirdPerson", "third_person_screen", false); + + private final NumberValue distance = num("spatialGuiDistance", "distance", 1.5f, 0.3f, 6.0f); + private final NumberValue sideOffset = num("spatialGuiSideOffset", "side_offset", 0.0f, -3.0f, 3.0f); + private final NumberValue heightOffset = num("spatialGuiHeightOffset", "height_offset", 0.0f, -3.0f, 3.0f); + private final NumberValue yawOffset = num("spatialGuiYawOffset", "yaw_offset", 180.0f, 0.0f, 360.0f); + private final NumberValue pitchOffset = num("spatialGuiPitchOffset", "pitch_offset", 0.0f, -90.0f, 90.0f); + private final NumberValue scale = num("spatialGuiScale", "scale", 1.8f, 0.3f, 6.0f); + private final NumberValue alpha = num("spatialGuiAlpha", "alpha", 255, 0, 255); + + private final BooleanValue parallax = bool("spatialGuiParallax", "parallax", true); + private final NumberValue parallaxStrength = visibleWhen( + num("spatialGuiParallaxStrength", "parallax_strength", 0.05f, 0.0f, 0.3f), parallax::get); + + private final BooleanValue animate = bool("spatialGuiAnimate", "animate", true); + private final ModeValue animationStyle = visibleWhen( + mode("spatialGuiAnimationStyle", "Scale", "Scale", "Slide", "Fade"), animate::get); + private final NumberValue animationDurationMs = visibleWhen( + num("spatialGuiAnimationDuration", "animation_duration_ms", 220, 40, 2000), animate::get); + + private Screen trackedScreen; + private long openTimeMs; + private long closeTimeMs = -1; + private GpuSampler cachedSampler; + private float openYaw; + private float openPitch; + private CameraType previousCameraType; + + public SpatialGui() { + instance = this; + } + + public static SpatialGui getInstance() { + return instance; + } + + @Override + public WorldPhase getWorldPhase() { + return WorldPhase.AFTER_POST_PROCESS; + } + + /** Whether the currently open screen (if any) should be captured and floated in 3D this frame. */ + public boolean isCapturing() { + if (!isEnabled()) return false; + Screen screen = Minecraft.getInstance().gui.screen(); + if (screen == null) return false; + return !containerOnly.get() || screen instanceof AbstractContainerScreen; + } + + public boolean isThirdPerson() { + return thirdPerson.get(); + } + + @Override + public void onFrame(float tickDelta) { + Screen current = isEnabled() ? Minecraft.getInstance().gui.screen() : null; + boolean eligible = current != null && (!containerOnly.get() || current instanceof AbstractContainerScreen); + Screen effective = eligible ? current : null; + + if (effective != trackedScreen) { + Options options = Minecraft.getInstance().options; + if (effective != null) { + openTimeMs = System.currentTimeMillis(); + closeTimeMs = -1; + Camera camera = Minecraft.getInstance().gameRenderer.mainCamera(); + openYaw = camera.yRot(); + openPitch = camera.xRot(); + if (thirdPerson.get() && options.getCameraType().isFirstPerson()) { + previousCameraType = options.getCameraType(); + options.setCameraType(CameraType.THIRD_PERSON_BACK); + } else { + previousCameraType = null; + } + } else { + if (trackedScreen != null) { + closeTimeMs = System.currentTimeMillis(); + } + if (previousCameraType != null) { + options.setCameraType(previousCameraType); + previousCameraType = null; + } + } + trackedScreen = effective; + } + } + + @Override + public void onDisable() { + if (previousCameraType != null) { + Minecraft.getInstance().options.setCameraType(previousCameraType); + previousCameraType = null; + } + trackedScreen = null; + closeTimeMs = -1; + SpatialGuiPlane.clear(); + } + + private float animationProgress() { + if (!animate.get()) return 1.0F; + long duration = Math.max(1, animationDurationMs.get()); + if (trackedScreen != null) { + long elapsed = System.currentTimeMillis() - openTimeMs; + return Math.min(1.0F, (float) elapsed / duration); + } + if (closeTimeMs >= 0) { + long elapsed = System.currentTimeMillis() - closeTimeMs; + return Math.max(0.0F, 1.0F - (float) elapsed / duration); + } + return 0.0F; + } + + @Override + public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null) return; + + boolean liveScreen = trackedScreen != null; + float progress = animationProgress(); + if (progress <= 0.0F && !liveScreen) { + SpatialGuiPlane.clear(); + return; + } + + SpatialGuiCapture capture = SpatialGuiCapture.get(); + if (!capture.isReady()) { + SpatialGuiPlane.clear(); + return; + } + GpuTextureView textureView = capture.colorTextureView(); + if (textureView == null) { + SpatialGuiPlane.clear(); + return; + } + + Camera camera = mc.gameRenderer.mainCamera(); + Vector3fc forward = camera.forwardVector(); + Vector3f camPos = new Vector3f((float) camera.position().x, (float) camera.position().y, (float) camera.position().z); + + // Build the plane basis from the player's flat (yaw-only) facing direction so it stays upright, + // then apply the configurable yaw/pitch offsets on top (yaw ~ 180 faces the plane back at the player, + // like Spatial-GUI's default "screen held up in front of you" orientation). + Vector3f worldUp = new Vector3f(0.0F, 1.0F, 0.0F); + Vector3f flatForward = new Vector3f(forward.x(), 0.0F, forward.z()); + if (flatForward.lengthSquared() < 1.0e-6F) flatForward = new Vector3f(0.0F, 0.0F, 1.0F); + flatForward.normalize(); + + Vector3f planeForward = new Vector3f(flatForward) + .rotateAxis((float) Math.toRadians(yawOffset.get()), worldUp.x, worldUp.y, worldUp.z); + Vector3f planeRight = new Vector3f(planeForward).cross(worldUp, new Vector3f()).normalize().mul(-1.0F); + Vector3f planeUp = new Vector3f(planeRight).cross(planeForward, new Vector3f()).normalize(); + + float pitchRadians = (float) Math.toRadians(pitchOffset.get()); + if (pitchRadians != 0.0F) { + planeForward = planeForward.rotateAxis(pitchRadians, planeRight.x, planeRight.y, planeRight.z); + planeUp = new Vector3f(planeRight).cross(planeForward, new Vector3f()).normalize(); + } + + float dist = distance.get(); + float parallaxRight = 0.0F; + float parallaxUp = 0.0F; + if (parallax.get()) { + // Subtle depth-parallax: as the player turns their head away from the angle the screen opened at, + // nudge the plane opposite to that turn so it reads as a physical object floating in the world + // rather than a sprite glued to the camera (mirrors Spatial-GUI's head-tracked parallax). + float yawDelta = wrapDegrees(camera.yRot() - openYaw); + float pitchDelta = camera.xRot() - openPitch; + float strength = parallaxStrength.get(); + parallaxRight = -yawDelta * strength * 0.05F; + parallaxUp = pitchDelta * strength * 0.05F; + } + + Vector3f center = new Vector3f(camPos) + .add(new Vector3f(planeForward).mul(dist)) + .add(new Vector3f(planeRight).mul(sideOffset.get() + parallaxRight)) + .add(new Vector3f(planeUp).mul(heightOffset.get() + parallaxUp)); + + int guiWidth = Math.max(1, capture.width()); + int guiHeight = Math.max(1, capture.height()); + float aspect = (float) guiWidth / (float) guiHeight; + + float animScale = scale.get(); + float baseAlpha = alpha.get() / 255.0F; + String style = animationStyle.get(); + if ("Scale".equals(style)) { + animScale *= 0.6F + 0.4F * ease(progress); + } else if ("Slide".equals(style)) { + center = new Vector3f(center).add(new Vector3f(planeUp).mul((1.0F - ease(progress)) * -0.6F)); + } + float drawAlpha = "Fade".equals(style) ? baseAlpha * ease(progress) : baseAlpha; + if (drawAlpha <= 0.001F) { + SpatialGuiPlane.clear(); + return; + } + + float halfHeight = animScale * 0.5F; + float halfWidth = halfHeight * aspect; + + Vector3f p1 = new Vector3f(center).sub(new Vector3f(planeRight).mul(halfWidth)).sub(new Vector3f(planeUp).mul(halfHeight)); + Vector3f p2 = new Vector3f(center).add(new Vector3f(planeRight).mul(halfWidth)).sub(new Vector3f(planeUp).mul(halfHeight)); + Vector3f p3 = new Vector3f(center).add(new Vector3f(planeRight).mul(halfWidth)).add(new Vector3f(planeUp).mul(halfHeight)); + Vector3f p4 = new Vector3f(center).sub(new Vector3f(planeRight).mul(halfWidth)).add(new Vector3f(planeUp).mul(halfHeight)); + + if (cachedSampler == null) { + cachedSampler = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR); + } + + MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, textureView, cachedSampler, Renderer3D.DepthMode.NONE); + if (mesh != null) { + int a = (int) (drawAlpha * 255.0F); + mesh.ensureQuadCapacity(); + int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(255, 255, 255, a).next(); + int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(255, 255, 255, a).next(); + int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(255, 255, 255, a).next(); + int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(255, 255, 255, a).next(); + mesh.quad(i1, i2, i3, i4); + } + + if (liveScreen) { + SpatialGuiPlane.publish(new SpatialGuiPlane(center, planeRight, planeUp, halfWidth, halfHeight, guiWidth, guiHeight)); + } else { + SpatialGuiPlane.clear(); + } + } + + private static float ease(float t) { + float t1 = t - 1.0F; + return t1 * t1 * t1 + 1.0F; + } + + private static float wrapDegrees(float degrees) { + float wrapped = degrees % 360.0F; + if (wrapped >= 180.0F) wrapped -= 360.0F; + if (wrapped < -180.0F) wrapped += 360.0F; + return wrapped; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/Wings.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/Wings.java index b327960..c8981ad 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/Wings.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/Wings.java @@ -24,7 +24,7 @@ import net.minecraft.world.item.Items; @ModuleInfo( id = "wings", displayName = "Wings", - category = ModuleCategory.VISUALS, + category = ModuleCategory.ACCESSORIES, description = "Крылья на спине игрока: 8 типов формы, анимация взмахов" ) public final class Wings extends Module { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRenderer.java index 5b15e9b..f75106e 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRenderer.java @@ -153,13 +153,29 @@ public final class WingsRenderer { } private static int put(MeshBuilder mesh, Matrix4f mat, float x, float y, float z, int argb) { - float wx = mat.m00() * x + mat.m01() * y + mat.m02() * z + mat.m03(); - float wy = mat.m10() * x + mat.m11() * y + mat.m12() * z + mat.m13(); - float wz = mat.m20() * x + mat.m21() * y + mat.m22() * z + mat.m23(); - mesh.vec3(wx, wy, wz).color((argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF, (argb >>> 24) & 0xFF); + Vec3 world = transformPosition(mat, x, y, z); + mesh.vec3((float) world.x, (float) world.y, (float) world.z) + .color((argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF, (argb >>> 24) & 0xFF); return mesh.next(); } + /** + * Transforms a local-space point by {@code mat} (dest = M * v). JOML's + * Matrix4f is column-major (m<col><row>), so a position transform + * reads DOWN columns 0/1/2 - see {@code Vector3f.mulPositionGeneric}. A + * previous row-wise implementation here read mat.m03()/m13()/m23() (the + * always-zero perspective row on an affine matrix) instead of the real + * translation column (m30/m31/m32), so the player's world position was + * never applied and every wing vertex landed near the world origin + * instead of on the player's back - the module rendered nothing visible. + */ + static Vec3 transformPosition(Matrix4f mat, float x, float y, float z) { + float wx = mat.m00() * x + mat.m10() * y + mat.m20() * z + mat.m30(); + float wy = mat.m01() * x + mat.m11() * y + mat.m21() * z + mat.m31(); + float wz = mat.m02() * x + mat.m12() * y + mat.m22() * z + mat.m32(); + return new Vec3(wx, wy, wz); + } + private static WingShapes.Pose resolvePose(Player player, float tickDelta) { if (player.isFallFlying()) { float flightTicks = (float) player.getFallFlyingTicks() + tickDelta; diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/BackendEndpoint.java b/mod/src/main/java/dev/loki/lovisual/features/platform/BackendEndpoint.java new file mode 100644 index 0000000..621ee8f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/BackendEndpoint.java @@ -0,0 +1,48 @@ +package dev.loki.lovisual.features.platform; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Pure normalization for a configured backend base URL: trims whitespace and a + * trailing slash, and falls back to a caller-supplied default whenever the + * stored value is blank or fails to parse as an absolute URI with a host. + * Kept dependency-free (no HTTP client, no Minecraft types) so it can be unit + * tested in isolation and reused once real API calls are added later. + */ +public final class BackendEndpoint { + + private BackendEndpoint() { + } + + public static String normalize(String rawValue, String fallback) { + String candidate = stripTrailingSlash(rawValue); + if (isValidBaseUrl(candidate)) { + return candidate; + } + return stripTrailingSlash(fallback); + } + + private static String stripTrailingSlash(String value) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private static boolean isValidBaseUrl(String value) { + if (value.isBlank()) { + return false; + } + try { + URI uri = new URI(value); + return uri.isAbsolute() && uri.getHost() != null && !uri.getHost().isBlank(); + } catch (URISyntaxException e) { + return false; + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java b/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java new file mode 100644 index 0000000..fff7cc0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.platform; + +import dev.loki.lovisual.config.definition.ConfigNameProvider; +import dev.loki.lovisual.config.definition.SettingDef; +import dev.loki.lovisual.config.definition.SettingOwner; +import dev.loki.lovisual.config.io.ConfigSerializer; +import dev.loki.lovisual.config.io.JsonConfigObject; +import dev.loki.lovisual.config.values.collection.ConfigValue; +import dev.loki.lovisual.config.values.primitive.StringValue; + +import java.util.List; + +/** + * Runtime-configurable client settings for talking to LoVisual's own backend + * (accounts-service / gateway), as opposed to third-party endpoints such as + * Mojang or Microsoft auth. Kept separate from {@link dev.loki.lovisual.config.i18n.MainConfig} + * (already near the file's line cap) and from {@link dev.loki.lovisual.features.account.AccountConfig} + * (which stores account *data*, not connection settings), so future + * mod-backend integration code has one obvious place to read the configured + * base URL. No HTTP client or auth flow lives here; that is out of scope + * until the actual integration work starts. + */ +public final class PlatformConfig implements JsonConfigObject, ConfigNameProvider, SettingOwner { + + public static final PlatformConfig INSTANCE = new PlatformConfig(); + + /** + * No production domain has been decided anywhere in the repo yet (checked + * frontend/.env.example, backend/PLAN.md, backend/gateway/PLAN.md, + * backend/STRUCTURE.md and TODO.md). This is a clearly-marked placeholder, + * not a real address; set it in the ClickGUI or config file once a real + * host exists. + */ + public static final String DEFAULT_BACKEND_BASE_URL = "https://api.lovisual.example"; + + private final StringValue backendBaseUrl = new StringValue("backendBaseUrl", DEFAULT_BACKEND_BASE_URL); + + private PlatformConfig() { + ConfigSerializer.load(this); + } + + public static PlatformConfig get() { + return INSTANCE; + } + + /** Normalized, always-usable base URL: trimmed, no trailing slash, falls back to the default when invalid. */ + public String getBackendBaseUrl() { + return BackendEndpoint.normalize(backendBaseUrl.get(), DEFAULT_BACKEND_BASE_URL); + } + + public void setBackendBaseUrl(String url) { + backendBaseUrl.set(url == null ? "" : url); + ConfigSerializer.requestSave(this); + } + + public List getSettingDefs() { + return List.of(SettingDef.text(backendBaseUrl)); + } + + @Override + public String getConfigName() { + return "platform"; + } + + @Override + public String name() { + return "platform_config"; + } + + @Override + public void saveConfig() { + ConfigSerializer.requestSave(this); + } + + @Override + public List> getConfigValues() { + return List.of(backendBaseUrl); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformSession.java b/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformSession.java new file mode 100644 index 0000000..f64a40a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformSession.java @@ -0,0 +1,70 @@ +package dev.loki.lovisual.features.platform; + +import dev.loki.lovisual.config.definition.ConfigNameProvider; +import dev.loki.lovisual.config.definition.SettingOwner; +import dev.loki.lovisual.config.io.ConfigSerializer; +import dev.loki.lovisual.config.io.JsonConfigObject; +import dev.loki.lovisual.config.values.collection.ConfigValue; +import dev.loki.lovisual.config.values.primitive.StringValue; + +import java.util.List; + +/** + * Persists the mod's link to a LoVisual platform account: only the opaque + * {@code lvd_...} device token handed out by {@code POST /device/token}, + * never a password or a site access/refresh token. Structured the same way + * as {@link PlatformConfig} (a {@link JsonConfigObject} singleton through + * {@link ConfigSerializer}), stored separately from it because a session can + * be cleared (unlink) independently of the configured backend URL. + */ +public final class PlatformSession implements JsonConfigObject, ConfigNameProvider, SettingOwner { + + public static final PlatformSession INSTANCE = new PlatformSession(); + + private final StringValue deviceToken = new StringValue("deviceToken", ""); + + private PlatformSession() { + ConfigSerializer.load(this); + } + + public static PlatformSession get() { + return INSTANCE; + } + + public boolean isLinked() { + return !deviceToken.get().isBlank(); + } + + public String getDeviceToken() { + return deviceToken.get(); + } + + public void setDeviceToken(String token) { + deviceToken.set(token == null ? "" : token.trim()); + ConfigSerializer.requestSave(this); + } + + public void clear() { + setDeviceToken(""); + } + + @Override + public List> getConfigValues() { + return List.of(deviceToken); + } + + @Override + public String getConfigName() { + return "platform_session"; + } + + @Override + public String name() { + return "platform_session"; + } + + @Override + public void saveConfig() { + ConfigSerializer.requestSave(this); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java new file mode 100644 index 0000000..3768354 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java @@ -0,0 +1,78 @@ +package dev.loki.lovisual.features.platform.config; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.config.profile.service.ConfigProfileApplier; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.http.ApiResult; +import dev.loki.lovisual.features.platform.http.PlatformHttpClient; +import dev.loki.lovisual.features.platform.json.ConfigProfileJsonCodec; +import net.minecraft.client.Minecraft; + +/** + * Backs {@code %config load }: fetches a publicly shared config from + * {@code GET /configs/shared/{code}} (no auth required per + * {@code configs-service/PLAN.md}), decodes its {@code data} field into a + * {@link ConfigProfileSnapshot} via {@link ConfigProfileJsonCodec}, and + * applies it through the existing {@link ConfigProfileApplier} rather than a + * second "apply settings" code path. Kept out of + * {@code features.command.impl.client.ConfigCommand} to respect both that + * file's line budget and the 4-files-per-folder rule for its package. + */ +public final class ConfigShareLoader { + + private static final ConfigProfileApplier APPLIER = new ConfigProfileApplier(); + + private ConfigShareLoader() { + } + + public static void load(String rawCode) { + String code = rawCode == null ? "" : rawCode.trim(); + if (code.isEmpty()) { + CommandOutput.warning("Usage: %config load "); + return; + } + CommandOutput.send("Loading shared config " + code + "..."); + PlatformHttpClient.get("/configs/shared/" + code, null).thenAccept(ConfigShareLoader::onLoaded); + } + + private static void onLoaded(ApiResult result) { + dispatch(() -> { + if (result.isNotFound()) { + CommandOutput.error("No shared config found for that code."); + return; + } + if (!result.success() || result.asObject() == null) { + CommandOutput.error("Failed to load shared config: " + result.error()); + return; + } + applyShared(result.asObject()); + }); + } + + private static void applyShared(JsonObject response) { + if (!response.has("data") || !response.get("data").isJsonObject()) { + CommandOutput.error("Shared config has no usable data."); + return; + } + String name = response.has("name") && !response.get("name").isJsonNull() + ? response.get("name").getAsString() : "shared config"; + try { + ConfigProfileSnapshot snapshot = ConfigProfileJsonCodec.fromJson(response.getAsJsonObject("data")); + ConfigProfileApplier.ApplyResult applied = APPLIER.apply(snapshot); + CommandOutput.success("Applied \"" + name + "\" (" + applied.appliedOwners() + " settings groups, " + + applied.missingOwners() + " missing)."); + } catch (RuntimeException e) { + CommandOutput.error("Could not decode shared config: " + e.getMessage()); + } + } + + private static void dispatch(Runnable runnable) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.isSameThread()) { + runnable.run(); + } else { + mc.execute(runnable); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/http/ApiResult.java b/mod/src/main/java/dev/loki/lovisual/features/platform/http/ApiResult.java new file mode 100644 index 0000000..8101862 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/http/ApiResult.java @@ -0,0 +1,54 @@ +package dev.loki.lovisual.features.platform.http; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; + +/** + * Typed outcome of a {@link PlatformHttpClient} call: either a 2xx response + * with a parsed JSON body, or a failure (non-2xx status or a network/parse + * error) with a human-readable message. Callers switch on {@link #success()} + * instead of catching exceptions across the HTTP boundary. + */ +public record ApiResult(boolean success, int statusCode, JsonElement body, String error) { + + public static ApiResult of(int statusCode, String rawBody) { + JsonElement parsed = parse(rawBody); + boolean ok = statusCode >= 200 && statusCode < 300; + return new ApiResult(ok, statusCode, parsed, ok ? null : errorMessage(parsed, statusCode)); + } + + public static ApiResult networkError(String message) { + return new ApiResult(false, -1, null, message == null || message.isBlank() ? "Network error" : message); + } + + public JsonObject asObject() { + return body != null && body.isJsonObject() ? body.getAsJsonObject() : null; + } + + public boolean isNotFound() { + return statusCode == 404; + } + + public boolean isPending() { + return statusCode == 202; + } + + private static JsonElement parse(String rawBody) { + if (rawBody == null || rawBody.isBlank()) return null; + try { + return JsonParser.parseString(rawBody); + } catch (JsonSyntaxException e) { + return null; + } + } + + private static String errorMessage(JsonElement parsed, int statusCode) { + if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("error")) { + JsonElement value = parsed.getAsJsonObject().get("error"); + if (value.isJsonPrimitive()) return value.getAsString(); + } + return "HTTP " + statusCode; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java b/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java new file mode 100644 index 0000000..2f44bb5 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java @@ -0,0 +1,84 @@ +package dev.loki.lovisual.features.platform.http; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.features.platform.PlatformConfig; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Small reusable client for talking to the LoVisual gateway. Resolves the + * base URL via {@link PlatformConfig}/{@link dev.loki.lovisual.features.platform.BackendEndpoint}, + * sends/receives JSON, attaches the device token as a bearer header when one + * is supplied, and always runs the blocking network call off the caller's + * thread (mirrors {@code SkinManager}'s dedicated-executor pattern) so it is + * safe to call from the render/client thread. Non-2xx responses are mapped + * into {@link ApiResult} rather than thrown as exceptions. + */ +public final class PlatformHttpClient { + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15); + + private static final HttpClient CLIENT = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + + private static final Executor EXECUTOR = Executors.newFixedThreadPool(2, runnable -> { + Thread thread = new Thread(runnable, "LoVisual-PlatformHttp"); + thread.setDaemon(true); + return thread; + }); + + private PlatformHttpClient() { + } + + public static CompletableFuture get(String path, String bearerToken) { + return send(requestBuilder(path).GET(), bearerToken); + } + + public static CompletableFuture post(String path, JsonObject body, String bearerToken) { + String json = body == null ? "{}" : body.toString(); + HttpRequest.Builder builder = requestBuilder(path) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)); + return send(builder, bearerToken); + } + + public static CompletableFuture delete(String path, String bearerToken) { + return send(requestBuilder(path).DELETE(), bearerToken); + } + + private static HttpRequest.Builder requestBuilder(String path) { + return HttpRequest.newBuilder(URI.create(PlatformConfig.get().getBackendBaseUrl() + path)) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json"); + } + + private static CompletableFuture send(HttpRequest.Builder builder, String bearerToken) { + if (bearerToken != null && !bearerToken.isBlank()) { + builder.header("Authorization", "Bearer " + bearerToken); + } + HttpRequest request = builder.build(); + return CompletableFuture.supplyAsync(() -> executeBlocking(request), EXECUTOR); + } + + private static ApiResult executeBlocking(HttpRequest request) { + try { + HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + return ApiResult.of(response.statusCode(), response.body()); + } catch (IOException e) { + return ApiResult.networkError(e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiResult.networkError("Interrupted"); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java b/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java new file mode 100644 index 0000000..12d1cd0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java @@ -0,0 +1,167 @@ +package dev.loki.lovisual.features.platform.json; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import dev.loki.lovisual.config.profile.codec.ConfigProfileEntry; +import dev.loki.lovisual.config.profile.codec.ConfigProfileType; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileMeta; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Adapter between {@link ConfigProfileSnapshot} (LoVisual's local + * export/import shape, normally persisted through the binary + * {@code ConfigProfileBinaryCodec}) and a plain JSON tree, so a snapshot can + * travel as the {@code data} field of the backend's + * {@code GET /configs/shared/{code}} response. + * + *

This is a genuine adapter, not a re-skin: the local format is a + * compressed, CRC32-checked binary blob written by + * {@code ConfigProfileBinaryCodec}, which is not what a JSONB column can + * hold. The backend's own plan docs do not yet define an exact shape for + * {@code data} (as of this pass, mod-side upload is still unimplemented, and + * {@code configs-service/PLAN.md} only fixes its own easter-egg payload as + * an example) so the shape below ({@code {meta:{...}, entries:[{ownerId, + * displayName, values}]}}) is this task's judgment call: it mirrors + * {@link ConfigProfileEntry}/{@link ConfigProfileMeta} field-for-field so a + * future upload path can reuse it unchanged, and it round-trips the value + * types {@code ConfigValue#fromJson} accepts (booleans, numbers, strings, + * lists, maps) via plain Gson-native representations. + */ +public final class ConfigProfileJsonCodec { + + private ConfigProfileJsonCodec() { + } + + public static JsonObject toJson(ConfigProfileSnapshot snapshot) { + if (snapshot == null) throw new IllegalArgumentException("snapshot is null"); + JsonObject root = new JsonObject(); + root.add("meta", metaToJson(snapshot.meta())); + JsonArray entries = new JsonArray(); + for (ConfigProfileEntry entry : snapshot.entries()) { + entries.add(entryToJson(entry)); + } + root.add("entries", entries); + return root; + } + + public static ConfigProfileSnapshot fromJson(JsonObject root) { + if (root == null) throw new IllegalArgumentException("json is null"); + ConfigProfileMeta meta = metaFromJson(root.getAsJsonObject("meta")); + List entries = new ArrayList<>(); + JsonArray array = root.getAsJsonArray("entries"); + if (array != null) { + for (JsonElement element : array) { + if (element.isJsonObject()) entries.add(entryFromJson(element.getAsJsonObject())); + } + } + return new ConfigProfileSnapshot(meta, entries); + } + + private static JsonObject metaToJson(ConfigProfileMeta meta) { + JsonObject json = new JsonObject(); + json.addProperty("id", meta.id()); + json.addProperty("name", meta.name()); + json.addProperty("type", meta.type().folderName()); + json.addProperty("author", meta.author()); + json.addProperty("createdAt", meta.createdAt()); + json.addProperty("updatedAt", meta.updatedAt()); + json.addProperty("version", meta.version()); + return json; + } + + private static ConfigProfileMeta metaFromJson(JsonObject json) { + if (json == null) { + return new ConfigProfileMeta(null, null, ConfigProfileType.MODULES, null, 0, 0, 0); + } + return new ConfigProfileMeta( + stringOf(json, "id"), + stringOf(json, "name"), + ConfigProfileType.fromName(stringOf(json, "type")), + stringOf(json, "author"), + longOf(json, "createdAt"), + longOf(json, "updatedAt"), + (int) longOf(json, "version") + ); + } + + private static JsonObject entryToJson(ConfigProfileEntry entry) { + JsonObject json = new JsonObject(); + json.addProperty("ownerId", entry.ownerId()); + json.addProperty("displayName", entry.displayName()); + JsonObject values = new JsonObject(); + for (Map.Entry value : entry.values().entrySet()) { + values.add(value.getKey(), toElement(value.getValue())); + } + json.add("values", values); + return json; + } + + private static ConfigProfileEntry entryFromJson(JsonObject json) { + Map values = new LinkedHashMap<>(); + JsonObject valuesJson = json.getAsJsonObject("values"); + if (valuesJson != null) { + for (Map.Entry entry : valuesJson.entrySet()) { + values.put(entry.getKey(), toObject(entry.getValue())); + } + } + return new ConfigProfileEntry(stringOf(json, "ownerId"), stringOf(json, "displayName"), values); + } + + private static JsonElement toElement(Object value) { + if (value == null) return com.google.gson.JsonNull.INSTANCE; + if (value instanceof Boolean b) return new JsonPrimitive(b); + if (value instanceof Number n) return new JsonPrimitive(n); + if (value instanceof CharSequence s) return new JsonPrimitive(s.toString()); + if (value instanceof Iterable iterable) { + JsonArray array = new JsonArray(); + for (Object item : iterable) array.add(toElement(item)); + return array; + } + if (value instanceof Map map) { + JsonObject object = new JsonObject(); + for (Map.Entry entry : map.entrySet()) { + object.add(String.valueOf(entry.getKey()), toElement(entry.getValue())); + } + return object; + } + return new JsonPrimitive(String.valueOf(value)); + } + + private static Object toObject(JsonElement element) { + if (element == null || element.isJsonNull()) return null; + if (element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isBoolean()) return primitive.getAsBoolean(); + if (primitive.isNumber()) return primitive.getAsDouble(); + return primitive.getAsString(); + } + if (element.isJsonArray()) { + List list = new ArrayList<>(); + for (JsonElement item : element.getAsJsonArray()) list.add(toObject(item)); + return list; + } + if (element.isJsonObject()) { + Map map = new LinkedHashMap<>(); + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + map.put(entry.getKey(), toObject(entry.getValue())); + } + return map; + } + return null; + } + + private static String stringOf(JsonObject json, String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; + } + + private static long longOf(JsonObject json, String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : 0L; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/link/DeviceLinkFlow.java b/mod/src/main/java/dev/loki/lovisual/features/platform/link/DeviceLinkFlow.java new file mode 100644 index 0000000..c528256 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/link/DeviceLinkFlow.java @@ -0,0 +1,151 @@ +package dev.loki.lovisual.features.platform.link; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.features.platform.PlatformSession; +import dev.loki.lovisual.features.platform.http.ApiResult; +import dev.loki.lovisual.features.platform.http.PlatformHttpClient; +import net.minecraft.client.Minecraft; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Drives LoVisual's own OAuth-device-flow-style login against the gateway: + * {@code POST /device/code} to get a short user code, then polls + * {@code POST /device/token} with the {@code device_code} until it is + * confirmed on the site, expires, or is cancelled. Structurally the same + * shape as {@code AltMicrosoftAuth}'s Microsoft device-code flow, but talks + * to LoVisual's own backend and stores only a device token via + * {@link PlatformSession} (never a password). + */ +public final class DeviceLinkFlow { + + private static final ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "LoVisual-DeviceLinkPoll"); + thread.setDaemon(true); + return thread; + }); + + private static final long POLL_INTERVAL_SECONDS = 4; + + private volatile boolean cancelled; + private ScheduledFuture pollTask; + + public void start(Consumer onUpdate) { + cancelled = false; + PlatformHttpClient.post("/device/code", new JsonObject(), null) + .thenAccept(result -> dispatch(() -> handleCodeResponse(result, onUpdate))); + } + + public void cancel() { + cancelled = true; + if (pollTask != null) { + pollTask.cancel(false); + } + } + + private void handleCodeResponse(ApiResult result, Consumer onUpdate) { + if (cancelled) { + return; + } + if (!result.success() || result.asObject() == null) { + onUpdate.accept(Result.failure(result.error() == null ? "Failed to request a device code" : result.error())); + return; + } + JsonObject body = result.asObject(); + String deviceCode = text(body, "device_code"); + String userCode = text(body, "user_code"); + long expiresIn = body.has("expires_in") ? body.get("expires_in").getAsLong() : 600L; + if (deviceCode.isBlank() || userCode.isBlank()) { + onUpdate.accept(Result.failure("Malformed /device/code response")); + return; + } + onUpdate.accept(Result.awaitingConfirmation(userCode)); + long deadline = System.currentTimeMillis() + Math.max(1, expiresIn) * 1000L; + schedulePoll(deviceCode, deadline, onUpdate); + } + + private void schedulePoll(String deviceCode, long deadline, Consumer onUpdate) { + if (cancelled) { + return; + } + if (System.currentTimeMillis() >= deadline) { + onUpdate.accept(Result.failure("Device code expired")); + return; + } + pollTask = SCHEDULER.schedule(() -> poll(deviceCode, deadline, onUpdate), POLL_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + private void poll(String deviceCode, long deadline, Consumer onUpdate) { + if (cancelled) { + return; + } + JsonObject body = new JsonObject(); + body.addProperty("device_code", deviceCode); + PlatformHttpClient.post("/device/token", body, null) + .thenAccept(result -> dispatch(() -> handlePollResponse(result, deviceCode, deadline, onUpdate))); + } + + private void handlePollResponse(ApiResult result, String deviceCode, long deadline, Consumer onUpdate) { + if (cancelled) { + return; + } + if (result.isNotFound()) { + onUpdate.accept(Result.failure("Device code expired or unknown")); + return; + } + if (result.isPending()) { + schedulePoll(deviceCode, deadline, onUpdate); + return; + } + if (!result.success() || result.asObject() == null) { + schedulePoll(deviceCode, deadline, onUpdate); + return; + } + String deviceToken = text(result.asObject(), "device_token"); + if (deviceToken.isBlank()) { + schedulePoll(deviceCode, deadline, onUpdate); + return; + } + PlatformSession.get().setDeviceToken(deviceToken); + onUpdate.accept(Result.linked()); + } + + private static void dispatch(Runnable runnable) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.isSameThread()) { + runnable.run(); + } else { + mc.execute(runnable); + } + } + + private static String text(JsonObject object, String key) { + return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : ""; + } + + public enum Status { AWAITING_CONFIRMATION, LINKED, FAILED } + + public record Result(Status status, String userCode, String message) { + static Result awaitingConfirmation(String userCode) { + return new Result(Status.AWAITING_CONFIRMATION, userCode, null); + } + + static Result linked() { + return new Result(Status.LINKED, null, null); + } + + static Result failure(String message) { + return new Result(Status.FAILED, null, message); + } + } + + /** Fire-and-forget {@code GET /me} using the currently stored device token. */ + public static CompletableFuture fetchMe() { + return PlatformHttpClient.get("/me", PlatformSession.get().getDeviceToken()); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/entity/living/LivingEntityRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/entity/living/LivingEntityRendererMixin.java index 0c92602..7555cff 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/entity/living/LivingEntityRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/entity/living/LivingEntityRendererMixin.java @@ -25,11 +25,8 @@ import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import dev.loki.lovisual.features.module.lifecycle.Modules; -import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.InvisEsp; import dev.loki.lovisual.features.module.modules.visuals.nametags.NameTags; import dev.loki.lovisual.features.module.modules.visuals.visibility.NoRender; -import dev.loki.lovisual.render.invisible.SeeInvisiblesReveal; -import dev.loki.lovisual.render.invisible.SeeInvisiblesState; import dev.loki.lovisual.render.fade.ViewObstructionFadeContext; import dev.loki.lovisual.render.fade.ViewObstructionFadeState; import dev.loki.lovisual.render.iris.runtime.IrisRuntime; @@ -56,24 +53,6 @@ public abstract class LivingEntityRendererMixin original) { - if (!(state2 instanceof LivingEntityRenderState livingState) || !SeeInvisiblesReveal.isRevealedTranslucent(livingState)) { - original.call(collector, model, state2, poseStack, renderType, light, overlay, color, sprite, outlineColor, crumbling); - return; - } - float priorAlpha = ViewObstructionFadeContext.isActive() ? ViewObstructionFadeContext.alpha() : 1.0f; - ViewObstructionFadeContext.push(true, priorAlpha * SeeInvisiblesReveal.revealAlpha()); - try { - original.call(collector, model, state2, poseStack, renderType, light, overlay, color, sprite, outlineColor, crumbling); - } finally { - ViewObstructionFadeContext.pop(); - } - } - @Inject(method = "submit", at = @At("RETURN")) private void lovisual$popViewObstructionFadeContext(S state, com.mojang.blaze3d.vertex.PoseStack matrixStack, net.minecraft.client.renderer.SubmitNodeCollector orderedRenderCommandQueue, net.minecraft.client.renderer.state.level.CameraRenderState cameraRenderState, CallbackInfo ci) { ViewObstructionFadeContext.pop(); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/input/spatialgui/MouseHandlerSpatialGuiMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/input/spatialgui/MouseHandlerSpatialGuiMixin.java new file mode 100644 index 0000000..bc43919 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/input/spatialgui/MouseHandlerSpatialGuiMixin.java @@ -0,0 +1,115 @@ +/* + * Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License. + * Copyright (c) tastytrash and Spatial GUI contributors. + * + * This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms + * for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed + * portions ported from Spatial GUI remain available under the MIT terms above. + */ +package dev.loki.lovisual.mixins.input.spatialgui; + +import com.mojang.blaze3d.platform.Window; +import dev.loki.lovisual.features.module.modules.visuals.spatialgui.SpatialGui; +import dev.loki.lovisual.render.spatialgui.SpatialGuiPlane; +import net.minecraft.client.Camera; +import net.minecraft.client.MouseHandler; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Options; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Adapted from Spatial-GUI's {@code MouseHandlerUtil}/{@code MouseHandlerMixin}: reprojects the real window + * cursor onto the plane the open screen is currently drawn on (published every frame by {@link SpatialGuiPlane}) + * so that clicking/dragging/scrolling on the 3D screen still resolves to the right 2D GUI coordinate. + *

+ * {@link MouseHandler#getScaledXPos(Window)} and {@link MouseHandler#getScaledYPos(Window)} are the single choke + * point every mouse event (click, release, move, drag, scroll) goes through to turn the raw cursor position into + * the coordinate handed to {@code Screen}, so overriding just these two covers all of them. + */ +@Mixin(MouseHandler.class) +public abstract class MouseHandlerSpatialGuiMixin { + + @Shadow + public abstract double xpos(); + + @Shadow + public abstract double ypos(); + + private final float[] lovisual$hit = new float[2]; + private long lovisual$hitStampNanos = Long.MIN_VALUE; + private boolean lovisual$hitValid; + + @Inject(method = "getScaledXPos(Lcom/mojang/blaze3d/platform/Window;)D", at = @At("RETURN"), cancellable = true) + private void lovisual$overrideScaledX(Window window, CallbackInfoReturnable cir) { + if (lovisual$resolveHit(window)) { + cir.setReturnValue((double) lovisual$hit[0]); + } + } + + @Inject(method = "getScaledYPos(Lcom/mojang/blaze3d/platform/Window;)D", at = @At("RETURN"), cancellable = true) + private void lovisual$overrideScaledY(Window window, CallbackInfoReturnable cir) { + if (lovisual$resolveHit(window)) { + cir.setReturnValue((double) lovisual$hit[1]); + } + } + + /** + * Casts a ray from the camera through the real cursor pixel and intersects it with the published spatial + * GUI plane. Cached per-frame since X and Y are queried back-to-back for the same event. + */ + private boolean lovisual$resolveHit(Window window) { + SpatialGui module = SpatialGui.getInstance(); + if (module == null || !module.isCapturing()) return false; + SpatialGuiPlane plane = SpatialGuiPlane.current(); + if (plane == null) return false; + + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gameRenderer == null) return false; + + long now = System.nanoTime(); + // getScaledXPos/getScaledYPos are always called back-to-back for the same mouse event; a ~2ms window + // is enough to reuse one raycast for both without needing a real per-frame counter. + if (lovisual$hitStampNanos != Long.MIN_VALUE && now - lovisual$hitStampNanos < 2_000_000L) { + return lovisual$hitValid; + } + lovisual$hitStampNanos = now; + + int screenWidth = window.getScreenWidth(); + int screenHeight = window.getScreenHeight(); + if (screenWidth <= 0 || screenHeight <= 0) { + lovisual$hitValid = false; + return false; + } + + double pixelX = xpos(); + double pixelY = ypos(); + float ndcX = (float) (2.0 * pixelX / screenWidth - 1.0); + float ndcY = (float) (1.0 - 2.0 * pixelY / screenHeight); + + Camera camera = mc.gameRenderer.mainCamera(); + Options options = mc.options; + double fovDeg = options != null ? options.fov().get() : 70.0; + float aspect = (float) screenWidth / (float) screenHeight; + float tanHalfFov = (float) Math.tan(Math.toRadians(fovDeg) * 0.5); + + Vector3fc forward = camera.forwardVector(); + Vector3fc left = camera.leftVector(); + Vector3fc up = camera.upVector(); + + Vector3f direction = new Vector3f(forward) + .add(new Vector3f(left).mul(-ndcX * tanHalfFov * aspect)) + .add(new Vector3f(up).mul(ndcY * tanHalfFov)) + .normalize(); + + Vector3f origin = new Vector3f((float) camera.position().x, (float) camera.position().y, (float) camera.position().z); + + lovisual$hitValid = plane.rayToGuiCoords(origin, direction, lovisual$hit); + return lovisual$hitValid; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/entity/LivingEntityRenderStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/entity/LivingEntityRenderStateMixin.java index e350c1c..4ba4de7 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/entity/LivingEntityRenderStateMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/entity/LivingEntityRenderStateMixin.java @@ -3,30 +3,16 @@ package dev.loki.lovisual.mixins.render.entity; import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; -import dev.loki.lovisual.render.invisible.SeeInvisiblesState; import dev.loki.lovisual.render.fade.ViewObstructionFadeState; @Mixin(LivingEntityRenderState.class) -public abstract class LivingEntityRenderStateMixin implements ViewObstructionFadeState, SeeInvisiblesState { +public abstract class LivingEntityRenderStateMixin implements ViewObstructionFadeState { @Unique private boolean lovisual$viewObstructionFadeActive; @Unique private float lovisual$viewObstructionFadeAlpha = 1.0f; - @Unique - private boolean lovisual$seeInvisRevealed; - - @Override - public boolean lovisual$isSeeInvisRevealed() { - return lovisual$seeInvisRevealed; - } - - @Override - public void lovisual$setSeeInvisRevealed(boolean revealed) { - this.lovisual$seeInvisRevealed = revealed; - } - @Override public boolean lovisual$isViewObstructionFadeActive() { return lovisual$viewObstructionFadeActive; diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/spatialgui/GuiRendererCaptureMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/spatialgui/GuiRendererCaptureMixin.java new file mode 100644 index 0000000..10a74b2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/spatialgui/GuiRendererCaptureMixin.java @@ -0,0 +1,99 @@ +/* + * Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License. + * Copyright (c) tastytrash and Spatial GUI contributors. + * + * This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms + * for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed + * portions ported from Spatial GUI remain available under the MIT terms above. + */ +package dev.loki.lovisual.mixins.render.spatialgui; + +import com.mojang.blaze3d.ProjectionType; +import com.mojang.blaze3d.buffers.GpuBufferSlice; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.systems.RenderSystem; +import dev.loki.lovisual.features.module.modules.visuals.spatialgui.SpatialGui; +import dev.loki.lovisual.render.spatialgui.SpatialGuiCapture; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.render.GuiRenderer; +import net.minecraft.client.renderer.Projection; +import net.minecraft.client.renderer.ProjectionMatrixBuffer; +import net.minecraft.client.renderer.state.WindowRenderState; +import org.joml.Matrix4f; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; +import java.util.function.Supplier; + +/** + * Adapted from Spatial-GUI's {@code ScreenExtractor}/{@code TextureTargetManager}: on this Minecraft version the + * whole 2D GUI pass is a deferred draw list executed once per frame by {@link GuiRenderer#draw()} against an + * explicit {@link RenderTarget}. Redirecting the currently open screen into a 3D plane therefore only requires + * pointing that one draw call at {@link SpatialGuiCapture}'s off-screen target instead of the main render + * target, and cancelling the vanilla draw so nothing shows up as a flat overlay. + *

+ * The vertex data itself is prepared and uploaded earlier in {@code GuiRenderer.render()} (which this mixin does + * not touch), so this only ever affects where the already-built draw list is executed to. + */ +@Mixin(value = GuiRenderer.class, priority = 900) +public abstract class GuiRendererCaptureMixin { + + @Shadow + @Final + private List draws; + + @Shadow + private int firstDrawIndexAfterBlur; + + @Shadow + @Final + private Projection guiProjection; + + @Shadow + @Final + private ProjectionMatrixBuffer guiProjectionMatrixBuffer; + + @Shadow + private void executeDrawRange(Supplier label, RenderTarget target, GpuBufferSlice transform, int startIndex, int endIndex) { + throw new AssertionError(); + } + + @Inject(method = "draw", at = @At("HEAD"), cancellable = true) + private void lovisual$captureToSpatialPlane(CallbackInfo ci) { + if (draws == null || draws.isEmpty()) return; + + SpatialGui module = SpatialGui.getInstance(); + if (module == null || !module.isCapturing()) return; + + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gameRenderer == null) return; + WindowRenderState windowState = mc.gameRenderer.gameRenderState().windowRenderState; + if (windowState == null || windowState.guiScale <= 0) return; + + SpatialGuiCapture capture = SpatialGuiCapture.get(); + RenderTarget target = capture.ensure(windowState.width, windowState.height); + capture.clearTransparent(); + + guiProjection.setupOrtho(1000.0F, 11000.0F, + (float) windowState.width / windowState.guiScale, (float) windowState.height / windowState.guiScale, true); + RenderSystem.setProjectionMatrix(guiProjectionMatrixBuffer.getBuffer(guiProjection), ProjectionType.ORTHOGRAPHIC); + GpuBufferSlice transform = RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f().setTranslation(0.0F, 0.0F, -11000.0F)); + + int splitIndex = Math.min(firstDrawIndexAfterBlur, draws.size()); + if (splitIndex > 0) { + executeDrawRange(() -> "spatial gui before blur", target, transform, 0, splitIndex); + } + if (draws.size() > firstDrawIndexAfterBlur) { + RenderSystem.getDevice().createCommandEncoder().clearDepthTexture(target.getDepthTexture(), 0.0D); + mc.gameRenderer.processBlurEffect(); + executeDrawRange(() -> "spatial gui after blur", target, transform, firstDrawIndexAfterBlur, draws.size()); + } + + ci.cancel(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/invisible/SeeInvisiblesReveal.java b/mod/src/main/java/dev/loki/lovisual/render/invisible/SeeInvisiblesReveal.java deleted file mode 100644 index dd0c082..0000000 --- a/mod/src/main/java/dev/loki/lovisual/render/invisible/SeeInvisiblesReveal.java +++ /dev/null @@ -1,61 +0,0 @@ -package dev.loki.lovisual.render.invisible; - -import dev.loki.lovisual.features.module.lifecycle.Modules; -import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.InvisEsp; -import dev.loki.lovisual.render.iris.runtime.IrisRuntime; -import net.minecraft.client.Minecraft; -import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; -import net.minecraft.client.renderer.rendertype.RenderType; -import net.minecraft.client.renderer.rendertype.RenderTypes; -import net.minecraft.resources.Identifier; -import net.minecraft.world.entity.LivingEntity; -import net.minecraft.world.entity.player.Player; - -/** - * Логика See Invisibles: невидимый игрок раскрывается — флаг невидимости - * снимается перед рендером, а render type форсится в полупрозрачный. - */ -public enum SeeInvisiblesReveal { - ; - - public static void reveal(T entity, LivingEntityRenderState state) { - if (!(entity instanceof Player player) || player == Minecraft.getInstance().player) { - return; - } - InvisEsp module = Modules.get(InvisEsp.class); - if (module == null || !module.isEnabled() || !entity.isInvisible()) { - return; - } - if (state instanceof SeeInvisiblesState seeInvis) { - seeInvis.lovisual$setSeeInvisRevealed(true); - } - state.isInvisible = false; - } - - /** @return true if the entity was revealed this frame and should render below full opacity. */ - public static boolean isRevealedTranslucent(LivingEntityRenderState state) { - InvisEsp module = Modules.get(InvisEsp.class); - return module != null && module.isEnabled() && module.isTranslucentReveal() - && state instanceof SeeInvisiblesState seeInvis && seeInvis.lovisual$isSeeInvisRevealed(); - } - - /** @return the configured InvisESP reveal opacity, or fully opaque if the module is missing. */ - public static float revealAlpha() { - InvisEsp module = Modules.get(InvisEsp.class); - return module == null ? 1.0f : module.getOpacity(); - } - - public static RenderType renderTypeFor(RenderType original, Identifier texture) { - InvisEsp module = Modules.get(InvisEsp.class); - if (module == null || !module.isEnabled() || IrisRuntime.isRenderingShadowPass()) { - return original; - } - if (!module.isTranslucentReveal()) { - return original; - } - if (original != null && original.hasBlending()) { - return original; - } - return RenderTypes.entityTranslucent(texture, true); - } -} diff --git a/mod/src/main/java/dev/loki/lovisual/render/invisible/SeeInvisiblesState.java b/mod/src/main/java/dev/loki/lovisual/render/invisible/SeeInvisiblesState.java deleted file mode 100644 index ba9ad09..0000000 --- a/mod/src/main/java/dev/loki/lovisual/render/invisible/SeeInvisiblesState.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.loki.lovisual.render.invisible; - -/** - * Marker injected into entity render states when InvisESP reveals - * an invisible player this frame. - */ -public interface SeeInvisiblesState { - public boolean lovisual$isSeeInvisRevealed(); - - public void lovisual$setSeeInvisRevealed(boolean revealed); -} diff --git a/mod/src/main/java/dev/loki/lovisual/render/spatialgui/SpatialGuiCapture.java b/mod/src/main/java/dev/loki/lovisual/render/spatialgui/SpatialGuiCapture.java new file mode 100644 index 0000000..226c349 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/spatialgui/SpatialGuiCapture.java @@ -0,0 +1,94 @@ +/* + * Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License. + * Copyright (c) tastytrash and Spatial GUI contributors. + * + * This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms + * for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed + * portions ported from Spatial GUI remain available under the MIT terms above. + */ +package dev.loki.lovisual.render.spatialgui; + +import com.mojang.blaze3d.GpuFormat; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.GpuTexture; +import com.mojang.blaze3d.textures.GpuTextureView; +import org.joml.Vector4f; + +/** + * Owns the off-screen {@link RenderTarget} that the currently open {@link net.minecraft.client.gui.screens.Screen} + * is redirected into when the SpatialGui module is displaying it as a 3D plane instead of a flat overlay. + *

+ * This replaces Spatial-GUI's {@code TextureTargetManager}/{@code ScreenExtractor} pair: on this Minecraft + * version the GUI is drawn through a deferred {@code GuiRenderer} that executes its draw list against an + * explicit {@link RenderTarget}, so capturing the screen only requires swapping that target for the duration + * of one draw call (see {@code GuiRendererCaptureMixin}) instead of hooking a legacy immediate-mode GL pass. + */ +public final class SpatialGuiCapture { + private static final SpatialGuiCapture INSTANCE = new SpatialGuiCapture(); + + private TextureTarget target; + private int width; + private int height; + + private SpatialGuiCapture() { + } + + public static SpatialGuiCapture get() { + return INSTANCE; + } + + /** + * Ensures the capture target exists and matches the requested pixel size. Must be called on the render thread. + */ + public RenderTarget ensure(int pixelWidth, int pixelHeight) { + pixelWidth = Math.max(1, pixelWidth); + pixelHeight = Math.max(1, pixelHeight); + if (target == null) { + target = new TextureTarget("LoVisual Spatial GUI", pixelWidth, pixelHeight, true, GpuFormat.RGBA8_UNORM); + width = pixelWidth; + height = pixelHeight; + } else if (width != pixelWidth || height != pixelHeight) { + target.resize(pixelWidth, pixelHeight); + width = pixelWidth; + height = pixelHeight; + } + return target; + } + + public void clearTransparent() { + if (target == null) return; + GpuTexture colorTexture = target.getColorTexture(); + if (colorTexture == null) return; + if (target.useDepth && target.getDepthTexture() != null) { + RenderSystem.getDevice().createCommandEncoder() + .clearColorAndDepthTextures(colorTexture, new Vector4f(0.0F, 0.0F, 0.0F, 0.0F), target.getDepthTexture(), 1.0D); + } else { + RenderSystem.getDevice().createCommandEncoder().clearColorTexture(colorTexture, new Vector4f(0.0F, 0.0F, 0.0F, 0.0F)); + } + } + + public GpuTextureView colorTextureView() { + return target == null ? null : target.getColorTextureView(); + } + + public boolean isReady() { + return target != null && target.getColorTextureView() != null; + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + public void close() { + if (target != null) { + target.destroyBuffers(); + target = null; + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/spatialgui/SpatialGuiPlane.java b/mod/src/main/java/dev/loki/lovisual/render/spatialgui/SpatialGuiPlane.java new file mode 100644 index 0000000..f495148 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/spatialgui/SpatialGuiPlane.java @@ -0,0 +1,77 @@ +/* + * Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License. + * Copyright (c) tastytrash and Spatial GUI contributors. + * + * This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms + * for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed + * portions ported from Spatial GUI remain available under the MIT terms above. + */ +package dev.loki.lovisual.render.spatialgui; + +import org.joml.Vector3f; + +/** + * World-space description of the plane the captured GUI texture was last drawn onto, published every frame by + * the SpatialGui module so the mouse-ray mixin can translate a window-space cursor position into a 2D GUI + * coordinate (the equivalent of Spatial-GUI's {@code MouseHandlerUtil}/{@code CameraUtil} pair). + */ +public final class SpatialGuiPlane { + private static volatile SpatialGuiPlane active; + + private final Vector3f center; + private final Vector3f right; + private final Vector3f up; + private final float halfWidth; + private final float halfHeight; + private final int guiWidth; + private final int guiHeight; + + public SpatialGuiPlane(Vector3f center, Vector3f right, Vector3f up, float halfWidth, float halfHeight, int guiWidth, int guiHeight) { + this.center = center; + this.right = right; + this.up = up; + this.halfWidth = halfWidth; + this.halfHeight = halfHeight; + this.guiWidth = guiWidth; + this.guiHeight = guiHeight; + } + + public static void publish(SpatialGuiPlane plane) { + active = plane; + } + + public static void clear() { + active = null; + } + + public static SpatialGuiPlane current() { + return active; + } + + /** + * Intersects a world-space ray with this plane and, if it hits within bounds, writes the equivalent + * scaled-GUI-coordinate (top-left origin, matching {@code Screen} mouse coordinates) into {@code out}. + */ + public boolean rayToGuiCoords(Vector3f origin, Vector3f direction, float[] out) { + Vector3f normal = new Vector3f(right).cross(up, new Vector3f()).normalize(); + float denom = normal.dot(direction); + if (Math.abs(denom) < 1.0e-6F) return false; + + Vector3f toCenter = new Vector3f(center).sub(origin); + float t = toCenter.dot(normal) / denom; + if (t <= 0.0F) return false; + + Vector3f hit = new Vector3f(direction).mul(t).add(origin).sub(center); + float localX = hit.dot(right) / (right.length() * right.length()); + float localY = hit.dot(up) / (up.length() * up.length()); + if (localX < -halfWidth || localX > halfWidth || localY < -halfHeight || localY > halfHeight) { + return false; + } + + float u = (localX + halfWidth) / (2.0F * halfWidth); + float v = 1.0F - (localY + halfHeight) / (2.0F * halfHeight); + out[0] = u * guiWidth; + out[1] = v * guiHeight; + return true; + } +} diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index 2d20d80..5fa436b 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -1315,6 +1315,8 @@ "setting.esp.entity_color": "Entity Color", "setting.esp.search_entities": "Search Entities", "setting.esp.render_self": "Render Self", + "setting.esp.only_visible": "Require Line of Sight (disable for wallhack)", + "setting.esp.only_armor": "Require Armor", "setting.cameraclip.distance_multiplier": "Distance multiplier", "setting.cameraclip.back_offset": "Back offset", "setting.cameraclip.right_offset": "Right offset", @@ -1953,8 +1955,6 @@ "setting.draggable.triangulator.triangulator_cache_lifetime": "Cache lifetime", "setting.draggable.triangulator.triangulator_auto_reset": "Auto reset", "module.autobed.description": "Automatically places and detonates beds in explosive dimensions.", - "module.invisesp.description": "Makes invisible players visible with adjustable translucency.", - "setting.invisesp.opacity": "Opacity", "module.itemphysic.description": "Gives dropped items physical behavior: they lie flat on the ground and spin in the air.", "setting.itemphysic.reduce_size": "Reduce item size", "setting.itemphysic.lay_flat": "Lie flat on ground", @@ -3417,5 +3417,6 @@ "setting.wings.ribs": "Ribs", "setting.wings.color": "Color", "setting.kineticlyrics.time_offset_ms.desc": "Shifts lyric timing vs the audible audio (ms). Positive = text later. Player position leads the speakers (Bluetooth: 0.5-1s+), so a negative default compensates. Tune with debug mode if it still drifts.", - "setting.kineticlyrics.arc_spread.desc": "How wide around your view lines may spawn; automatically clamped to your real horizontal FOV." + "setting.kineticlyrics.arc_spread.desc": "How wide around your view lines may spawn; automatically clamped to your real horizontal FOV.", + "command.link.description": "Link this client to your LoVisual account, or check/unlink an existing link." } diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 35134f4..3d57ed9 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -1105,6 +1105,8 @@ "setting.esp.entity_color": "Цвет сущностей", "setting.esp.search_entities": "Поиск сущностей", "setting.esp.render_self": "Отображать себя", + "setting.esp.only_visible": "Требовать прямую видимость (выключите для wallhack)", + "setting.esp.only_armor": "Только в броне", "setting.cameraclip.distance_multiplier": "Множитель дистанции", "setting.cameraclip.back_offset": "Смещение назад", "setting.cameraclip.right_offset": "Смещение вправо", @@ -1753,8 +1755,6 @@ "setting.draggable.triangulator.triangulator_cache_lifetime": "Время жизни кеша", "setting.draggable.triangulator.triangulator_auto_reset": "Автосброс", "module.autobed.description": "Автоматически ставит и взрывает кровати в измерениях, где они взрываются.", - "module.invisesp.description": "Делает невидимых игроков видимыми (полупрозрачно).", - "setting.invisesp.opacity": "Прозрачность", "module.itemphysic.description": "Даёт предметам на земле физику: лежат плашмя, крутятся в воздухе.", "setting.itemphysic.reduce_size": "Уменьшить размер", "setting.itemphysic.lay_flat": "Лежит плашмя на земле", @@ -3208,5 +3208,6 @@ "setting.wings.ribs": "Перья (рёбра)", "setting.wings.color": "Цвет", "setting.kineticlyrics.time_offset_ms.desc": "Сдвиг тайминга текста относительно слышимого звука (мс). Плюс = текст позже. Позиция плеера опережает динамики (Bluetooth: 0.5–1с+), дефолтный минус это компенсирует. Подстрой через debug, если всё ещё плывёт.", - "setting.kineticlyrics.arc_spread.desc": "Насколько широко вокруг обзора могут появляться строки; автоматически ограничивается реальным горизонтальным FOV." + "setting.kineticlyrics.arc_spread.desc": "Насколько широко вокруг обзора могут появляться строки; автоматически ограничивается реальным горизонтальным FOV.", + "command.link.description": "Привязывает клиент к аккаунту LoVisual или проверяет/отвязывает текущую привязку." } diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json index fe625ed..00a9cbf 100644 --- a/mod/src/main/resources/lovisual.mixins.json +++ b/mod/src/main/resources/lovisual.mixins.json @@ -125,6 +125,7 @@ "input.keyboard.KeyboardMixin", "input.mouse.InputMixin", "input.mouse.MouseMixin", + "input.spatialgui.MouseHandlerSpatialGuiMixin", "iris.hand.IrisHandRendererAccessor", "iris.hand.IrisHandRendererMixin", "iris.hand.IrisVanillaHandInteropMixin", @@ -166,6 +167,7 @@ "render.main.GameRendererViewEffectsMixin", "render.main.MinecraftMixin", "render.main.ScreenEffectRendererMixin", + "render.spatialgui.GuiRendererCaptureMixin", "render.sound.LibraryMixin", "render.sound.SoundEngineMixin", "render.text.FontManagerMixin", diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/MediaPlayerConstantsScrollTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/MediaPlayerConstantsScrollTest.java new file mode 100644 index 0000000..6b675a5 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/hud/draggable/impl/combat/MediaPlayerConstantsScrollTest.java @@ -0,0 +1,40 @@ +package dev.loki.lovisual.features.gui.hud.draggable.impl.combat; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for {@link MediaPlayerConstants#scrollOffset}, the pure marquee-scroll offset + * calculator shared by the title and artist rows of the MediaPlayer HUD widget. + */ +final class MediaPlayerConstantsScrollTest { + + @Test + void staysAtZeroDuringThePause() { + assertEquals(0f, MediaPlayerConstants.scrollOffset(0f, 100f, 20f, 0.8f)); + assertEquals(0f, MediaPlayerConstants.scrollOffset(0.5f, 100f, 20f, 0.8f)); + assertEquals(0f, MediaPlayerConstants.scrollOffset(0.8f, 100f, 20f, 0.8f)); + } + + @Test + void slidesLeftAtTheGivenSpeedAfterThePause() { + float offset = MediaPlayerConstants.scrollOffset(1.8f, 100f, 20f, 0.8f); + // 1.8s - 0.8s pause = 1.0s of travel at 20px/s. + assertEquals(-20f, offset, 1e-4f); + } + + @Test + void wrapsBackToZeroAtTheStartOfTheNextCycle() { + // cycleTime = 0.8 + 100/20 = 5.8s; just past that should read like t≈0 again. + float wrapped = MediaPlayerConstants.scrollOffset(5.9f, 100f, 20f, 0.8f); + float freshStart = MediaPlayerConstants.scrollOffset(0.1f, 100f, 20f, 0.8f); + assertEquals(freshStart, wrapped, 1e-4f); + } + + @Test + void returnsZeroForNonPositiveSpeed() { + assertEquals(0f, MediaPlayerConstants.scrollOffset(5f, 100f, 0f, 0.8f)); + assertEquals(0f, MediaPlayerConstants.scrollOffset(5f, 100f, -1f, 0.8f)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java index aadcd05..c1ce13b 100644 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java @@ -124,6 +124,28 @@ public final class LrcParserTest { assertEquals("Trim me", lines.get(0).text()); } + @Test + void activeLineIndexReturnsMinusOneBeforeFirstLine() { + List lines = LrcParser.parse("[00:10.00]First\n[00:20.00]Second"); + assertEquals(-1, LrcParser.activeLineIndex(lines, 5_000L)); + } + + @Test + void activeLineIndexReturnsMinusOneForEmptyOrNullList() { + assertEquals(-1, LrcParser.activeLineIndex(List.of(), 1_000L)); + assertEquals(-1, LrcParser.activeLineIndex(null, 1_000L)); + } + + @Test + void activeLineIndexTracksCurrentLineAsTimeAdvances() { + List lines = LrcParser.parse("[00:10.00]First\n[00:20.00]Second\n[00:30.00]Third"); + + assertEquals(0, LrcParser.activeLineIndex(lines, 10_000L)); + assertEquals(0, LrcParser.activeLineIndex(lines, 19_999L)); + assertEquals(1, LrcParser.activeLineIndex(lines, 20_000L)); + assertEquals(2, LrcParser.activeLineIndex(lines, 999_999L)); + } + @Test void handlesMultipleLinesWithMixedFormats() { String lrc = "[0:15.5]Short\n[01:30]No fraction\n[00:05.12]Two digits\n[00:05.123]Three digits"; diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRendererTransformTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRendererTransformTest.java new file mode 100644 index 0000000..9ae3b57 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/wings/WingsRendererTransformTest.java @@ -0,0 +1,51 @@ +package dev.loki.lovisual.features.module.modules.visuals.wings; + +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Regression test for the matrix bug that made {@link WingsRenderer} render nothing: + * {@code transformPosition} must agree with JOML's own column-major position + * transform (Vector3f.mulPosition), including the translation component - a + * row/column mix-up here previously dropped the player's world-space + * translation entirely, collapsing every wing vertex toward the world origin. + */ +final class WingsRendererTransformTest { + + @Test + void matchesJomlPositionTransformIncludingTranslation() { + Matrix4f mat = new Matrix4f() + .translate(100.5f, 64.0f, -200.25f) + .rotateY((float) Math.toRadians(37f)) + .rotateX((float) Math.toRadians(12f)) + .scale(1.4f); + + float lx = 0.86f, ly = -0.46f, lz = 0f; + + Vec3 actual = WingsRenderer.transformPosition(mat, lx, ly, lz); + + Vector3f expected = new Vector3f(lx, ly, lz).mulPosition(mat); + + assertEquals(expected.x, (float) actual.x, 1e-4f); + assertEquals(expected.y, (float) actual.y, 1e-4f); + assertEquals(expected.z, (float) actual.z, 1e-4f); + } + + @Test + void translationIsActuallyApplied() { + Matrix4f mat = new Matrix4f().translate(10f, 20f, 30f); + + Vec3 origin = WingsRenderer.transformPosition(mat, 0f, 0f, 0f); + + // Before the fix this returned (0, 0, 0): the translation column was + // never read, so a vertex at the local origin never moved to the + // player's world position. + assertEquals(10f, (float) origin.x, 1e-4f); + assertEquals(20f, (float) origin.y, 1e-4f); + assertEquals(30f, (float) origin.z, 1e-4f); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/BackendEndpointTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/BackendEndpointTest.java new file mode 100644 index 0000000..0ba9c2e --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/BackendEndpointTest.java @@ -0,0 +1,49 @@ +package dev.loki.lovisual.features.platform; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pure normalization tests for {@link BackendEndpoint}: no config file I/O + * and no singleton involved, matching the value-object test style used by + * dev.loki.lovisual.config.values.PrimitiveValuesTest. + */ +public final class BackendEndpointTest { + + private static final String FALLBACK = "https://api.lovisual.example"; + + @Test + void keepsAValidHttpsUrlUnchanged() { + assertEquals("https://api.lovisual.com", BackendEndpoint.normalize("https://api.lovisual.com", FALLBACK)); + } + + @Test + void trimsWhitespaceAndTrailingSlash() { + assertEquals("https://api.lovisual.com", BackendEndpoint.normalize(" https://api.lovisual.com/ ", FALLBACK)); + } + + @Test + void stripsMultipleTrailingSlashes() { + assertEquals("https://api.lovisual.com", BackendEndpoint.normalize("https://api.lovisual.com///", FALLBACK)); + } + + @Test + void fallsBackWhenValueIsBlank() { + assertEquals(FALLBACK, BackendEndpoint.normalize("", FALLBACK)); + assertEquals(FALLBACK, BackendEndpoint.normalize(" ", FALLBACK)); + assertEquals(FALLBACK, BackendEndpoint.normalize(null, FALLBACK)); + } + + @Test + void fallsBackWhenValueIsMalformed() { + assertEquals(FALLBACK, BackendEndpoint.normalize("not a url", FALLBACK)); + assertEquals(FALLBACK, BackendEndpoint.normalize("just-a-hostname", FALLBACK)); + assertEquals(FALLBACK, BackendEndpoint.normalize("://missing-scheme", FALLBACK)); + } + + @Test + void fallbackItselfIsAlsoNormalized() { + assertEquals("https://api.lovisual.example", BackendEndpoint.normalize(null, "https://api.lovisual.example/")); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/PlatformConfigValueTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/PlatformConfigValueTest.java new file mode 100644 index 0000000..b168108 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/PlatformConfigValueTest.java @@ -0,0 +1,52 @@ +package dev.loki.lovisual.features.platform; + +import dev.loki.lovisual.config.values.primitive.StringValue; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Covers the {@code backendBaseUrl} setting the way {@link PlatformConfig} + * declares it (same name/default), without touching the singleton or its + * on-disk config file. {@link dev.loki.lovisual.config.io.ConfigSerializer} + * persists a config object purely through each {@code ConfigValue}'s + * {@code toJson}/{@code fromJson}, so exercising that pair here is exactly + * what "round-trips through the config serializer" means, matching the style + * of dev.loki.lovisual.config.values.PrimitiveValuesTest. + */ +public final class PlatformConfigValueTest { + + @Test + void defaultsToProductionBackendUrlWhenNothingIsConfigured() { + StringValue value = new StringValue("backendBaseUrl", PlatformConfig.DEFAULT_BACKEND_BASE_URL); + assertEquals(PlatformConfig.DEFAULT_BACKEND_BASE_URL, value.get()); + assertEquals(PlatformConfig.DEFAULT_BACKEND_BASE_URL, BackendEndpoint.normalize(value.get(), PlatformConfig.DEFAULT_BACKEND_BASE_URL)); + } + + @Test + void overriddenValueRoundTripsThroughToJsonFromJson() { + StringValue value = new StringValue("backendBaseUrl", PlatformConfig.DEFAULT_BACKEND_BASE_URL); + value.fromJson("https://staging.lovisual.example"); + + Object serialized = value.toJson(); + assertEquals("https://staging.lovisual.example", serialized); + + StringValue reloaded = new StringValue("backendBaseUrl", PlatformConfig.DEFAULT_BACKEND_BASE_URL); + reloaded.fromJson(serialized); + assertEquals("https://staging.lovisual.example", reloaded.get()); + assertEquals("https://staging.lovisual.example", BackendEndpoint.normalize(reloaded.get(), PlatformConfig.DEFAULT_BACKEND_BASE_URL)); + } + + @Test + void malformedStoredValueFallsBackAfterRoundTrip() { + StringValue value = new StringValue("backendBaseUrl", PlatformConfig.DEFAULT_BACKEND_BASE_URL); + value.fromJson("not-a-url"); + + Object serialized = value.toJson(); + StringValue reloaded = new StringValue("backendBaseUrl", PlatformConfig.DEFAULT_BACKEND_BASE_URL); + reloaded.fromJson(serialized); + + assertEquals(PlatformConfig.DEFAULT_BACKEND_BASE_URL, + BackendEndpoint.normalize(reloaded.get(), PlatformConfig.DEFAULT_BACKEND_BASE_URL)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/http/ApiResultTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/http/ApiResultTest.java new file mode 100644 index 0000000..f1fac30 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/http/ApiResultTest.java @@ -0,0 +1,70 @@ +package dev.loki.lovisual.features.platform.http; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure JSON/status mapping logic of {@link ApiResult}, covering exactly the + * response shapes the backend plans document: 2xx bodies, {@code {"error": + * "..."}} error bodies, 404/202 markers used by the device-token poll and + * shared-config lookup, and unparseable/empty bodies. + */ +public final class ApiResultTest { + + @Test + void twoHundredWithBodyIsSuccess() { + ApiResult result = ApiResult.of(200, "{\"device_token\":\"lvd_abc\"}"); + assertTrue(result.success()); + assertEquals("lvd_abc", result.asObject().get("device_token").getAsString()); + assertNull(result.error()); + } + + @Test + void nonTwoHundredWithErrorBodyExposesMessage() { + ApiResult result = ApiResult.of(400, "{\"error\":\"bad request\"}"); + assertFalse(result.success()); + assertEquals("bad request", result.error()); + } + + @Test + void nonTwoHundredWithoutParsableBodyFallsBackToStatusCode() { + ApiResult result = ApiResult.of(500, "not json"); + assertFalse(result.success()); + assertEquals("HTTP 500", result.error()); + assertNull(result.body()); + } + + @Test + void emptyBodyOnSuccessIsHandled() { + ApiResult result = ApiResult.of(204, ""); + assertTrue(result.success()); + assertNull(result.body()); + assertNull(result.asObject()); + } + + @Test + void fourOhFourIsNotFoundAndTwoOhTwoIsPending() { + assertTrue(ApiResult.of(404, "{\"error\":\"unknown\"}").isNotFound()); + assertTrue(ApiResult.of(202, "{}").isPending()); + assertFalse(ApiResult.of(200, "{}").isNotFound()); + assertFalse(ApiResult.of(200, "{}").isPending()); + } + + @Test + void networkErrorHasNoStatusCodeAndCarriesTheMessage() { + ApiResult result = ApiResult.networkError("timed out"); + assertFalse(result.success()); + assertEquals(-1, result.statusCode()); + assertEquals("timed out", result.error()); + } + + @Test + void networkErrorFallsBackToGenericMessageWhenNoneGiven() { + ApiResult result = ApiResult.networkError(null); + assertEquals("Network error", result.error()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodecTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodecTest.java new file mode 100644 index 0000000..1bbe84e --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodecTest.java @@ -0,0 +1,88 @@ +package dev.loki.lovisual.features.platform.json; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.config.profile.codec.ConfigProfileEntry; +import dev.loki.lovisual.config.profile.codec.ConfigProfileType; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileMeta; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Round-trips {@link ConfigProfileSnapshot} through {@link ConfigProfileJsonCodec}, + * covering every value type {@code ConfigValue#fromJson} accepts (booleans, + * numbers, strings, nested lists/maps) plus meta fields, since this codec is + * the adapter between the on-disk binary profile format and the backend's + * JSON {@code data} field. + */ +public final class ConfigProfileJsonCodecTest { + + @Test + void roundTripsMetaAndSimpleValues() { + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("scale", 1.5); + values.put("label", "Hello"); + values.put("count", 42); + + ConfigProfileMeta meta = new ConfigProfileMeta("id1", "My Profile", ConfigProfileType.MODULES, "Tester", 1000L, 2000L, 1); + ConfigProfileSnapshot snapshot = new ConfigProfileSnapshot(meta, + List.of(new ConfigProfileEntry("module:Test", "Test", values))); + + JsonObject json = ConfigProfileJsonCodec.toJson(snapshot); + ConfigProfileSnapshot restored = ConfigProfileJsonCodec.fromJson(json); + + assertEquals("id1", restored.meta().id()); + assertEquals("My Profile", restored.meta().name()); + assertEquals(ConfigProfileType.MODULES, restored.meta().type()); + assertEquals("Tester", restored.meta().author()); + assertEquals(1000L, restored.meta().createdAt()); + assertEquals(2000L, restored.meta().updatedAt()); + assertEquals(1, restored.entries().size()); + + ConfigProfileEntry entry = restored.entries().get(0); + assertEquals("module:Test", entry.ownerId()); + assertEquals(Boolean.TRUE, entry.values().get("enabled")); + assertEquals(1.5, (Double) entry.values().get("scale")); + assertEquals("Hello", entry.values().get("label")); + assertEquals(42.0, (Double) entry.values().get("count")); + } + + @Test + void roundTripsNestedListsAndMaps() { + Map nested = new LinkedHashMap<>(); + nested.put("x", 1); + nested.put("y", 2); + Map values = new LinkedHashMap<>(); + values.put("tags", List.of("a", "b", "c")); + values.put("point", nested); + + ConfigProfileMeta meta = new ConfigProfileMeta("id2", "Nested", ConfigProfileType.HUD, "Tester", 1L, 2L, 1); + ConfigProfileSnapshot snapshot = new ConfigProfileSnapshot(meta, + List.of(new ConfigProfileEntry("hud.draggable:widget", "Widget", values))); + + ConfigProfileSnapshot restored = ConfigProfileJsonCodec.fromJson(ConfigProfileJsonCodec.toJson(snapshot)); + ConfigProfileEntry entry = restored.entries().get(0); + + Object tags = entry.values().get("tags"); + assertTrue(tags instanceof List); + assertEquals(List.of("a", "b", "c"), tags); + + Object point = entry.values().get("point"); + assertTrue(point instanceof Map); + assertEquals(1.0, (Double) ((Map) point).get("x")); + } + + @Test + void missingMetaAndEntriesDecodeToEmptyDefaults() { + ConfigProfileSnapshot restored = ConfigProfileJsonCodec.fromJson(new JsonObject()); + assertTrue(restored.entries().isEmpty()); + assertEquals(ConfigProfileType.MODULES, restored.meta().type()); + } +}