From e00ea8eb1143ab19a1dea6935638cdf455c8b4d5 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 1 Oct 2026 16:22:05 +0200 Subject: [PATCH] feat: frontend perf and a11y pass, plus mod visuals, hero main menu, Simple Voice Chat and vanilla chat autocomplete Frontend: - replace the i18next stack with a small typed i18n module - hero clouds removed, llms.txt, HUD widget ARIA fixes and tests Mod: - TargetESP constellation mode; tracker and colors extracted - JumpCircles: 7 new shapes, shared animation and color pipeline - KillEffect blood mode with Burst/Fountain/Ring/Spray styles - main menu: menuLayout setting (orbs/hero) and hero screen - Simple Voice Chat soft integration: VoiceChat module and HUD panel - renderer/quick: static Draw facade with glass outline - vanilla chat: Baritone-style autocomplete popup for the % command prefix --- frontend/bun.lock | 13 - frontend/package.json | 3 - frontend/public/llms.txt | 15 + frontend/src/app/i18n.ts | 88 ++-- frontend/src/app/i18next.d.ts | 31 -- frontend/src/features/account/api.ts | 2 +- .../account/components/DeviceList.tsx | 2 +- .../account/components/ProfileCard.tsx | 2 +- .../components/avatar/AvatarCropper.tsx | 2 +- .../components/avatar/AvatarHistoryStrip.tsx | 2 +- .../components/avatar/AvatarUpload.tsx | 2 +- .../features/account/tests/account.test.tsx | 2 +- frontend/src/features/auth/RequireAuth.tsx | 2 +- .../src/features/auth/forms/LoginForm.tsx | 2 +- .../src/features/auth/forms/RegisterForm.tsx | 2 +- frontend/src/features/auth/validation.ts | 2 +- .../src/features/clickgui/ClickGuiWindow.tsx | 2 +- .../clickgui/screens/ModulesScreen.tsx | 6 +- .../clickgui/screens/SettingsScreen.tsx | 2 +- .../configs/components/PublishForm.tsx | 2 +- .../features/configs/components/SlotPanel.tsx | 2 +- .../features/device-link/LinkDeviceForm.tsx | 2 +- .../src/features/download/DownloadButton.tsx | 2 +- .../src/features/download/ReleaseNotes.tsx | 2 +- frontend/src/features/landing/CommandHero.tsx | 2 +- frontend/src/features/landing/HowItWorks.tsx | 2 +- .../features/landing/effects/VoxelScene.tsx | 9 - .../landing/sections/explore/FaqDownload.tsx | 2 +- .../sections/explore/HudPlayground.tsx | 4 +- .../landing/sections/explore/ModuleWall.tsx | 2 +- .../sections/explore/ShowcaseTeaser.tsx | 2 +- .../features/landing/sections/hero/Hero.tsx | 2 +- .../landing/sections/hero/ThemeStrip.tsx | 2 +- .../landing/tests/interactions.test.tsx | 10 +- .../features/landing/tests/landing.test.tsx | 14 +- .../features/landing/tests/sections.test.tsx | 8 +- .../profile/components/PublicProfileView.tsx | 2 +- .../showcase/components/CopyToSlot.tsx | 2 +- .../showcase/components/ListingDetail.tsx | 2 +- .../showcase/components/ListingItem.tsx | 2 +- .../showcase/components/ListingList.tsx | 2 +- frontend/src/features/showcase/copies.ts | 2 +- .../features/themes/editor/ThemeEditor.tsx | 2 +- .../themes/editor/fields/ColorField.tsx | 2 +- .../themes/editor/fields/GradientField.tsx | 2 +- .../themes/editor/panel/ExportActions.tsx | 2 +- .../themes/editor/panel/PaletteTabs.tsx | 2 +- .../themes/editor/panel/PanelHeader.tsx | 2 +- .../themes/editor/panel/PresetPicker.tsx | 2 +- frontend/src/pages/app/ConfigsPage.tsx | 2 +- frontend/src/pages/app/LinkPage.tsx | 2 +- frontend/src/pages/download/DownloadPage.tsx | 2 +- frontend/src/pages/public/NotFoundPage.tsx | 2 +- frontend/src/pages/public/auth/AuthScreen.tsx | 2 +- frontend/src/pages/public/auth/LoginPage.tsx | 2 +- .../src/pages/public/auth/RegisterPage.tsx | 2 +- frontend/src/pages/showcase/ShowcasePage.tsx | 2 +- frontend/src/pages/themes/ThemesPage.tsx | 2 +- frontend/src/shared/api/errors.ts | 2 +- frontend/src/shared/easter/BreakableBlock.tsx | 2 +- frontend/src/shared/easter/KonamiEffect.tsx | 2 +- frontend/src/shared/i18n/I18nProvider.tsx | 8 + frontend/src/shared/i18n/LanguageSwitch.tsx | 2 +- frontend/src/shared/i18n/context.ts | 16 + frontend/src/shared/i18n/core.ts | 124 ++++++ frontend/src/shared/i18n/react.ts | 15 + frontend/src/shared/i18n/tests/core.test.ts | 48 ++ frontend/src/shared/i18n/tests/i18n.test.tsx | 7 +- frontend/src/shared/layout/AppShell.tsx | 2 +- frontend/src/shared/layout/Footer.tsx | 2 +- .../src/shared/layout/tests/layout.test.tsx | 2 +- .../src/shared/layout/topbar/MobileMenu.tsx | 2 +- frontend/src/shared/layout/topbar/NavPill.tsx | 2 +- frontend/src/shared/layout/topbar/TopBar.tsx | 2 +- frontend/src/shared/layout/topbar/parts.tsx | 2 +- .../src/shared/ui/share-code/ShareCode.tsx | 2 +- mod/TODO.md | 146 +++++++ mod/build.gradle | 14 + .../lovisual/hud/draggable/voicechat.json | 18 + mod/config/lovisual/mainconfig.json5 | 10 +- mod/docs/EVO_REPORT.md | 411 ++++++++++++++++++ mod/gradle.properties | 2 + .../voicechat/LoVisualVoicechatPlugin.java | 61 +++ .../compat/voicechat/VoiceChatBridge.java | 52 +++ .../lovisual/compat/voicechat/VoiceState.java | 120 +++++ .../loki/lovisual/config/i18n/MainConfig.java | 20 +- .../command/impl/world/TierCommand.java | 15 + .../command/suggest/SuggestionPlan.java | 38 ++ .../screen/modules/ModulesMenuPanel.java | 3 +- .../screen/modules/ModulesMenuResolver.java | 2 +- .../screen/modules/ModulesMenuScreen.java | 2 + .../modules/model/DescriptionLines.java | 45 ++ .../ModulesMenuDescriptionRenderer.java | 80 ++++ .../render/ModulesMenuModuleRenderer.java | 3 +- .../screen/settings/MenuSettingsResolver.java | 7 +- .../implement/main/MainSettingsComponent.java | 7 +- .../implement/main/MainSettingsModel.java | 8 + .../features/gui/hud/base/BaseHudElement.java | 2 + .../gui/hud/base/impl/HudElementNamer.java | 8 + .../impl/scripted/ScriptedListHudPanel.java | 3 + .../draggable/impl/voice/VoiceChatHud.java | 78 ++++ .../impl/voice/VoiceChatHudRenderer.java | 177 ++++++++ .../impl/buttons/state/ButtonQueue.java | 2 +- .../impl/tooltips/BetterTooltips.java | 13 +- .../tooltips/state/ShulkerPreviewCache.java | 2 +- .../impl/tooltips/state/TooltipColors.java | 22 + .../impl/tooltips/state/TooltipState.java | 5 +- .../features/gui/mainmenu/MainMenus.java | 41 ++ .../mainmenu/hero/LoVisualHeroMenuScreen.java | 200 +++++++++ .../gui/mainmenu/hero/logic/DayCycle.java | 31 ++ .../gui/mainmenu/hero/logic/HeroLayout.java | 130 ++++++ .../gui/mainmenu/hero/logic/Rect.java | 12 + .../gui/mainmenu/hero/logic/TextMetrics.java | 7 + .../hero/paint/HeroButtonPainter.java | 59 +++ .../mainmenu/hero/paint/HeroMenuRenderer.java | 85 ++++ .../gui/mainmenu/hero/paint/HeroPaint.java | 57 +++ .../hero/paint/HeroWidgetPainter.java | 79 ++++ .../gui/mainmenu/impl/MainMenuHoverLabel.java | 34 ++ .../gui/mainmenu/impl/MainMenuRenderer.java | 3 + .../gui/mainmenu/impl/MainMenuTextUtil.java | 26 +- .../modules/combat/prediction/TargetESP.java | 316 ++++---------- .../constellation/ConstellationLayout.java | 42 ++ .../constellation/TargetEspConstellation.java | 88 ++++ .../targetesp/state/TargetEspColors.java | 75 ++++ .../targetesp/state/TargetEspTracker.java | 53 +++ .../combat/targetesp/sword/SwordPhysics.java | 101 +++++ .../targetesp/sword/TargetEspSwords.java | 107 +++++ .../module/modules/misc/voice/VoiceChat.java | 100 +++++ .../misc/voice/VoiceIndicatorPulse.java | 20 + .../misc/voice/VoiceIndicatorRenderer.java | 74 ++++ .../visuals/effects/hits/KillEffect.java | 50 ++- .../effects/hits/blood/BloodGeometry.java | 121 ++++++ .../effects/hits/blood/BloodKillHelper.java | 104 +++++ .../effects/hits/blood/BloodParticles.java | 34 ++ .../effects/hits/blood/BloodPhysics.java | 129 ++++++ .../visuals/items/held/HoldMyItems.java | 22 + .../visuals/items/held/lua/HandFrame.java | 18 + .../visuals/items/held/lua/HandScripts.java | 175 ++++++++ .../visuals/items/held/lua/api/Easings.java | 68 +++ .../visuals/items/held/lua/api/EntityApi.java | 64 +++ .../visuals/items/held/lua/api/LuaTables.java | 32 ++ .../visuals/items/held/lua/api/PoseApi.java | 89 ++++ .../held/lua/runtime/LuaPreprocessor.java | 37 ++ .../items/held/lua/runtime/LuaSandbox.java | 65 +++ .../items/held/lua/runtime/ScriptSlot.java | 81 ++++ .../modules/visuals/player/JumpCircles.java | 308 +++++-------- .../player/jumpcircles/CircleAnimation.java | 56 +++ .../player/jumpcircles/CircleColorMode.java | 38 ++ .../player/jumpcircles/CircleRenderer.java | 88 ++++ .../player/jumpcircles/JumpCircle.java | 5 + .../player/jumpcircles/shape/Shape.java | 28 ++ .../jumpcircles/shape/ShapeGeometry.java | 176 ++++++++ .../player/jumpcircles/shape/ShapeMesh.java | 74 ++++ .../serverpolicy/ServerPolicyClient.java | 2 +- .../lovisual/features/theme/ThemeStore.java | 2 +- .../loki/lovisual/features/theme/Themes.java | 20 +- .../features/theme/impl/ThemesTransition.java | 12 +- .../features/theme/store/ThemeStoreIO.java | 4 +- .../hud}/GuiHealthLayoutHandler.java | 8 +- .../hud}/GuiHudBarHandler.java | 12 +- .../hud}/GuiOverlayHandler.java | 18 +- .../hud}/GuiTooltipHandler.java | 6 +- .../ClientPacketListenerPvpHandler.java | 14 +- .../ClientPacketListenerSoundHandler.java | 18 +- .../ClientPacketListenerStaffHandler.java | 12 +- .../player}/LocalPlayerInputHandler.java | 22 +- .../player}/LocalPlayerMovementHandler.java | 14 +- .../player}/LocalPlayerNetworkHandler.java | 14 +- .../player}/LocalPlayerRotationHandler.java | 18 +- .../entity/player/LocalPlayerMixin.java | 4 + .../gui/chat/CommandSuggestionsMixin.java | 58 +++ .../mixins/gui/screens/TitleScreenMixin.java | 8 +- .../loki/lovisual/mixins/hud/GuiMixin.java | 4 + .../game/ClientPacketListenerMixin.java | 3 + .../render/item/ItemInHandRendererMixin.java | 46 +- .../mixins/render/main/GameRendererMixin.java | 3 + .../render/engine/renderer/quick/Draw.java | 62 +++ .../engine/renderer/quick/GlassEdge.java | 33 ++ .../render/engine/texture/TextureStorage.java | 4 + .../GameRendererProjectionHelper.java | 18 +- .../gamerenderer}/GameRendererWorldDebug.java | 8 +- .../GameRendererWorldEngine.java | 10 +- .../gamerenderer}/GameRendererWorldPost.java | 14 +- .../assets/lovisual/holdmyitems/hand_pose.lua | 28 ++ .../assets/lovisual/holdmyitems/item_pose.lua | 18 + .../resources/assets/lovisual/lang/en_us.json | 219 +++++++++- .../resources/assets/lovisual/lang/ru_ru.json | 219 +++++++++- .../lovisual/textures/particles/sword.png | Bin 0 -> 279 bytes mod/src/main/resources/fabric.mod.json | 6 +- mod/src/main/resources/lovisual.mixins.json | 1 + .../compat/voicechat/VoiceStateTest.java | 112 +++++ .../command/suggest/SuggestionPlanTest.java | 41 ++ .../modules/model/DescriptionLinesTest.java | 39 ++ .../impl/tooltips/TooltipColorsTest.java | 21 + .../mainmenu/hero/logic/HeroLogicTest.java | 87 ++++ .../ConstellationLayoutTest.java | 36 ++ .../targetesp/sword/SwordPhysicsTest.java | 57 +++ .../misc/voice/VoiceIndicatorPulseTest.java | 28 ++ .../effects/hits/blood/BloodPhysicsTest.java | 95 ++++ .../items/held/lua/LuaSandboxTest.java | 79 ++++ .../jumpcircles/CircleAnimationTest.java | 56 +++ .../jumpcircles/shape/ShapeGeometryTest.java | 79 ++++ .../i18n/DescriptionCoverageTest.java | 67 +++ .../engine/renderer/quick/GlassEdgeTest.java | 28 ++ 204 files changed, 6469 insertions(+), 833 deletions(-) create mode 100644 frontend/public/llms.txt delete mode 100644 frontend/src/app/i18next.d.ts create mode 100644 frontend/src/shared/i18n/I18nProvider.tsx create mode 100644 frontend/src/shared/i18n/context.ts create mode 100644 frontend/src/shared/i18n/core.ts create mode 100644 frontend/src/shared/i18n/react.ts create mode 100644 frontend/src/shared/i18n/tests/core.test.ts create mode 100644 mod/config/lovisual/hud/draggable/voicechat.json create mode 100644 mod/docs/EVO_REPORT.md create mode 100644 mod/src/main/java/dev/loki/lovisual/compat/voicechat/LoVisualVoicechatPlugin.java create mode 100644 mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceChatBridge.java create mode 100644 mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceState.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/command/suggest/SuggestionPlan.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLines.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHud.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHudRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipColors.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/MainMenus.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/LoVisualHeroMenuScreen.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/DayCycle.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLayout.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/Rect.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/TextMetrics.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroButtonPainter.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroMenuRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroPaint.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroWidgetPainter.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuHoverLabel.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayout.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/TargetEspConstellation.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspColors.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspTracker.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysics.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/TargetEspSwords.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceChat.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulse.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodGeometry.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodKillHelper.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodParticles.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysics.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandFrame.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/Easings.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/EntityApi.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/LuaTables.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaPreprocessor.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaSandbox.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptSlot.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimation.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleColorMode.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/JumpCircle.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/Shape.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometry.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeMesh.java rename mod/src/main/java/dev/loki/lovisual/{mixins/hud/handlers => mixinhandlers/hud}/GuiHealthLayoutHandler.java (88%) rename mod/src/main/java/dev/loki/lovisual/{mixins/hud/handlers => mixinhandlers/hud}/GuiHudBarHandler.java (93%) rename mod/src/main/java/dev/loki/lovisual/{mixins/hud/handlers => mixinhandlers/hud}/GuiOverlayHandler.java (83%) rename mod/src/main/java/dev/loki/lovisual/{mixins/hud/handlers => mixinhandlers/hud}/GuiTooltipHandler.java (93%) rename mod/src/main/java/dev/loki/lovisual/{mixins/network/game/handlers => mixinhandlers/network}/ClientPacketListenerPvpHandler.java (92%) rename mod/src/main/java/dev/loki/lovisual/{mixins/network/game/handlers => mixinhandlers/network}/ClientPacketListenerSoundHandler.java (86%) rename mod/src/main/java/dev/loki/lovisual/{mixins/network/game/handlers => mixinhandlers/network}/ClientPacketListenerStaffHandler.java (88%) rename mod/src/main/java/dev/loki/lovisual/{mixins/entity/player/handlers => mixinhandlers/player}/LocalPlayerInputHandler.java (84%) rename mod/src/main/java/dev/loki/lovisual/{mixins/entity/player/handlers => mixinhandlers/player}/LocalPlayerMovementHandler.java (87%) rename mod/src/main/java/dev/loki/lovisual/{mixins/entity/player/handlers => mixinhandlers/player}/LocalPlayerNetworkHandler.java (81%) rename mod/src/main/java/dev/loki/lovisual/{mixins/entity/player/handlers => mixinhandlers/player}/LocalPlayerRotationHandler.java (84%) create mode 100644 mod/src/main/java/dev/loki/lovisual/mixins/gui/chat/CommandSuggestionsMixin.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/Draw.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdge.java rename mod/src/main/java/dev/loki/lovisual/{mixins/render/main/handlers => render/gamerenderer}/GameRendererProjectionHelper.java (90%) rename mod/src/main/java/dev/loki/lovisual/{mixins/render/main/handlers => render/gamerenderer}/GameRendererWorldDebug.java (94%) rename mod/src/main/java/dev/loki/lovisual/{mixins/render/main/handlers => render/gamerenderer}/GameRendererWorldEngine.java (97%) rename mod/src/main/java/dev/loki/lovisual/{mixins/render/main/handlers => render/gamerenderer}/GameRendererWorldPost.java (93%) create mode 100644 mod/src/main/resources/assets/lovisual/holdmyitems/hand_pose.lua create mode 100644 mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua create mode 100644 mod/src/main/resources/assets/lovisual/textures/particles/sword.png create mode 100644 mod/src/test/java/dev/loki/lovisual/compat/voicechat/VoiceStateTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/command/suggest/SuggestionPlanTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLinesTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/TooltipColorsTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLogicTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayoutTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysicsTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulseTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysicsTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/LuaSandboxTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimationTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometryTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/i18n/DescriptionCoverageTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdgeTest.java diff --git a/frontend/bun.lock b/frontend/bun.lock index c8f56ef..e87ddf8 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -10,11 +10,8 @@ "@fontsource-variable/unbounded": "^5.3.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.103.2", - "i18next": "^26.4.2", - "i18next-browser-languagedetector": "^8.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-i18next": "^17.0.15", "react-router": "^8.4.0", "tailwindcss": "^4.3.3", }, @@ -306,12 +303,6 @@ "html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="], - "html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="], - - "i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="], - - "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], - "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], @@ -378,8 +369,6 @@ "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - "react-i18next": ["react-i18next@17.0.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw=="], - "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="], @@ -428,8 +417,6 @@ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], - "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], "vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="], diff --git a/frontend/package.json b/frontend/package.json index 411d6bc..4eb458c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,11 +18,8 @@ "@fontsource-variable/unbounded": "^5.3.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.103.2", - "i18next": "^26.4.2", - "i18next-browser-languagedetector": "^8.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-i18next": "^17.0.15", "react-router": "^8.4.0", "tailwindcss": "^4.3.3" }, diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt new file mode 100644 index 0000000..bd37047 --- /dev/null +++ b/frontend/public/llms.txt @@ -0,0 +1,15 @@ +# LoVisual + +> LoVisual is a free Fabric mod for Minecraft with 80+ visual modules: wings, particles, a custom HUD, weather and 18 built-in themes. It is a visual mod, not a cheat client. This site hosts the download, a browser theme editor, a showcase of community configs and account tools. The site is available in Russian and English. + +## Main pages + +- [Home](https://visual.loki-code.dev/): what the mod does, live ClickGui and HUD demos +- [Download](https://visual.loki-code.dev/download): the mod jar and release notes +- [Themes](https://visual.loki-code.dev/themes): edit and preview ClickGui themes in the browser +- [Showcase](https://visual.loki-code.dev/showcase): configs and themes published by players + +## Notes for crawlers + +- [Sitemap](https://visual.loki-code.dev/sitemap.xml): list of public pages +- Account pages (`/account`, `/configs`, `/link`) require a login and contain nothing public. diff --git a/frontend/src/app/i18n.ts b/frontend/src/app/i18n.ts index f7c32c8..c42dc91 100644 --- a/frontend/src/app/i18n.ts +++ b/frontend/src/app/i18n.ts @@ -1,6 +1,5 @@ -import i18next, { type i18n } from 'i18next' -import LanguageDetector from 'i18next-browser-languagedetector' -import { initReactI18next } from 'react-i18next' +import { I18n, type Lang } from '@shared/i18n/core' +import { setGlobalI18n } from '@shared/i18n/react' import { accountEn } from '@features/account/i18n/en' import { accountRu } from '@features/account/i18n/ru' import { authEn } from '@features/auth/i18n/en' @@ -24,56 +23,39 @@ import { themesRu } from '@features/themes/i18n/ru' import { commonEn } from '@shared/i18n/common.en' import { commonRu } from '@shared/i18n/common.ru' -/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */ -export function initI18n(lng?: 'ru' | 'en'): i18n { - const instance = i18next.createInstance() - instance - .use(LanguageDetector) - .use(initReactI18next) - .init({ - lng, - resources: { - ru: { - common: commonRu, - auth: authRu, - landing: landingRu, - themes: themesRu, - clickgui: clickguiRu, - download: downloadRu, - deviceLink: deviceLinkRu, - account: accountRu, - configs: configsRu, - showcase: showcaseRu, - profile: profileRu, - }, - en: { - common: commonEn, - auth: authEn, - landing: landingEn, - themes: themesEn, - clickgui: clickguiEn, - download: downloadEn, - deviceLink: deviceLinkEn, - account: accountEn, - configs: configsEn, - showcase: showcaseEn, - profile: profileEn, - }, +/** Creates an i18n instance and makes it the global one. Each call is independent, so tests can run in isolation. */ +export function initI18n(lng?: Lang): I18n { + const instance = new I18n( + { + ru: { + common: commonRu, + auth: authRu, + landing: landingRu, + themes: themesRu, + clickgui: clickguiRu, + download: downloadRu, + deviceLink: deviceLinkRu, + account: accountRu, + configs: configsRu, + showcase: showcaseRu, + profile: profileRu, }, - // Browser language decides the default: only an actual `ru` locale gets Russian, - // every other system language falls back to English. - fallbackLng: 'en', - supportedLngs: ['ru', 'en'], - ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'], - defaultNS: 'common', - interpolation: { escapeValue: false }, - detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' }, - }) - - document.documentElement.lang = instance.language - instance.on('languageChanged', (next) => { - document.documentElement.lang = next - }) - + en: { + common: commonEn, + auth: authEn, + landing: landingEn, + themes: themesEn, + clickgui: clickguiEn, + download: downloadEn, + deviceLink: deviceLinkEn, + account: accountEn, + configs: configsEn, + showcase: showcaseEn, + profile: profileEn, + }, + }, + lng, + ) + setGlobalI18n(instance) return instance } diff --git a/frontend/src/app/i18next.d.ts b/frontend/src/app/i18next.d.ts deleted file mode 100644 index c4c8504..0000000 --- a/frontend/src/app/i18next.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { accountRu } from '@features/account/i18n/ru' -import type { authRu } from '@features/auth/i18n/ru' -import type { clickguiRu } from '@features/clickgui/i18n/ru' -import type { configsRu } from '@features/configs/i18n/ru' -import type { deviceLinkRu } from '@features/device-link/i18n/ru' -import type { downloadRu } from '@features/download/i18n/ru' -import type { landingRu } from '@features/landing/i18n/ru' -import type { profileRu } from '@features/profile/i18n/ru' -import type { showcaseRu } from '@features/showcase/i18n/ru' -import type { themesRu } from '@features/themes/i18n/ru' -import type { commonRu } from '@shared/i18n/common.ru' - -declare module 'i18next' { - interface CustomTypeOptions { - defaultNS: 'common' - resources: { - common: typeof commonRu - auth: typeof authRu - landing: typeof landingRu - themes: typeof themesRu - clickgui: typeof clickguiRu - download: typeof downloadRu - deviceLink: typeof deviceLinkRu - account: typeof accountRu - configs: typeof configsRu - showcase: typeof showcaseRu - profile: typeof profileRu - // Later tasks extend this with their own feature namespaces. - } - } -} diff --git a/frontend/src/features/account/api.ts b/frontend/src/features/account/api.ts index 6350f76..26a8996 100644 --- a/frontend/src/features/account/api.ts +++ b/frontend/src/features/account/api.ts @@ -1,5 +1,5 @@ import { api } from '@shared/api/client' -import type { TFunction } from 'i18next' +import type { TFunction } from '@shared/i18n/react' export type DeviceLink = { id: string; linked_at: string; last_seen: string | null } diff --git a/frontend/src/features/account/components/DeviceList.tsx b/frontend/src/features/account/components/DeviceList.tsx index fe3aca9..5ec8aca 100644 --- a/frontend/src/features/account/components/DeviceList.tsx +++ b/frontend/src/features/account/components/DeviceList.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { Button } from '@shared/ui/Button' import { listDevices, revokeDevice, type DeviceLink } from '../api' diff --git a/frontend/src/features/account/components/ProfileCard.tsx b/frontend/src/features/account/components/ProfileCard.tsx index 123c40f..d7c2c9f 100644 --- a/frontend/src/features/account/components/ProfileCard.tsx +++ b/frontend/src/features/account/components/ProfileCard.tsx @@ -1,5 +1,5 @@ import { useCallback, useRef, useState, type DragEvent } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { useNavigate } from 'react-router' import { Button } from '@shared/ui/Button' import { useSession } from '@features/auth/session' diff --git a/frontend/src/features/account/components/avatar/AvatarCropper.tsx b/frontend/src/features/account/components/avatar/AvatarCropper.tsx index 378348e..3bb1a67 100644 --- a/frontend/src/features/account/components/avatar/AvatarCropper.tsx +++ b/frontend/src/features/account/components/avatar/AvatarCropper.tsx @@ -1,5 +1,5 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Button } from '@shared/ui/Button' import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath' diff --git a/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx b/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx index 559839e..40487f4 100644 --- a/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx +++ b/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { listAvatarHistory, type AvatarHistoryEntry } from '@features/account/api' type Props = { diff --git a/frontend/src/features/account/components/avatar/AvatarUpload.tsx b/frontend/src/features/account/components/avatar/AvatarUpload.tsx index df47d2d..49cab7c 100644 --- a/frontend/src/features/account/components/avatar/AvatarUpload.tsx +++ b/frontend/src/features/account/components/avatar/AvatarUpload.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Notice } from '@shared/ui/Notice' import { useSession } from '@features/auth/session' import { diff --git a/frontend/src/features/account/tests/account.test.tsx b/frontend/src/features/account/tests/account.test.tsx index 2cdd4ce..debbc90 100644 --- a/frontend/src/features/account/tests/account.test.tsx +++ b/frontend/src/features/account/tests/account.test.tsx @@ -1,6 +1,6 @@ import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { getI18n } from 'react-i18next' +import { getI18n } from '@shared/i18n/react' import { expect, test } from 'vitest' import { checkAvatarFile } from '../api' import { DeviceList } from '@features/account/components/DeviceList' diff --git a/frontend/src/features/auth/RequireAuth.tsx b/frontend/src/features/auth/RequireAuth.tsx index 6ae3b10..71b91cf 100644 --- a/frontend/src/features/auth/RequireAuth.tsx +++ b/frontend/src/features/auth/RequireAuth.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Navigate, Outlet, useLocation } from 'react-router' import { useSession } from './session' diff --git a/frontend/src/features/auth/forms/LoginForm.tsx b/frontend/src/features/auth/forms/LoginForm.tsx index b3e93ac..3c0a391 100644 --- a/frontend/src/features/auth/forms/LoginForm.tsx +++ b/frontend/src/features/auth/forms/LoginForm.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { useLocation, useNavigate } from 'react-router' import { describeError } from '@shared/api/errors' import { Button } from '@shared/ui/Button' diff --git a/frontend/src/features/auth/forms/RegisterForm.tsx b/frontend/src/features/auth/forms/RegisterForm.tsx index bd4bde3..172f818 100644 --- a/frontend/src/features/auth/forms/RegisterForm.tsx +++ b/frontend/src/features/auth/forms/RegisterForm.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { useLocation, useNavigate } from 'react-router' import { describeError } from '@shared/api/errors' import { Button } from '@shared/ui/Button' diff --git a/frontend/src/features/auth/validation.ts b/frontend/src/features/auth/validation.ts index 0efca26..3484119 100644 --- a/frontend/src/features/auth/validation.ts +++ b/frontend/src/features/auth/validation.ts @@ -1,4 +1,4 @@ -import type { TFunction } from 'i18next' +import type { TFunction } from '@shared/i18n/react' export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined { const v = email.trim() diff --git a/frontend/src/features/clickgui/ClickGuiWindow.tsx b/frontend/src/features/clickgui/ClickGuiWindow.tsx index f1b34af..882dc63 100644 --- a/frontend/src/features/clickgui/ClickGuiWindow.tsx +++ b/frontend/src/features/clickgui/ClickGuiWindow.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { prefersReducedMotion } from '@shared/motion/reducedMotion' import type { ThemeEntry } from '../themes/types' import { useActiveTheme } from '../themes/useActiveTheme' diff --git a/frontend/src/features/clickgui/screens/ModulesScreen.tsx b/frontend/src/features/clickgui/screens/ModulesScreen.tsx index b46d493..d0c1cdb 100644 --- a/frontend/src/features/clickgui/screens/ModulesScreen.tsx +++ b/frontend/src/features/clickgui/screens/ModulesScreen.tsx @@ -1,5 +1,5 @@ import type { Dispatch } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import type { GuiAction, GuiState } from '../demo' import { Icon } from '../parts/Glyphs' import { type Category, CATEGORIES } from '../parts/modules' @@ -22,7 +22,7 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) { const { t } = useTranslation('clickgui') const titleId = `lv-gui-panel-${category.id}` return ( -
+

{category.name} @@ -51,6 +51,6 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) { ) })} -

+ ) } diff --git a/frontend/src/features/clickgui/screens/SettingsScreen.tsx b/frontend/src/features/clickgui/screens/SettingsScreen.tsx index 8a8c9e0..bac6a11 100644 --- a/frontend/src/features/clickgui/screens/SettingsScreen.tsx +++ b/frontend/src/features/clickgui/screens/SettingsScreen.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { PRESETS } from '@features/themes/presets.generated' import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '@features/themes/types' import { Icon } from '../parts/Glyphs' diff --git a/frontend/src/features/configs/components/PublishForm.tsx b/frontend/src/features/configs/components/PublishForm.tsx index e08414f..97f556e 100644 --- a/frontend/src/features/configs/components/PublishForm.tsx +++ b/frontend/src/features/configs/components/PublishForm.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Button } from '@shared/ui/Button' import { TextField } from '@shared/ui/text-field/TextField' import { publish } from '../api' diff --git a/frontend/src/features/configs/components/SlotPanel.tsx b/frontend/src/features/configs/components/SlotPanel.tsx index 4174a9f..c4c7332 100644 --- a/frontend/src/features/configs/components/SlotPanel.tsx +++ b/frontend/src/features/configs/components/SlotPanel.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useId, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Button } from '@shared/ui/Button' import { Notice } from '@shared/ui/Notice' import { ShareCode } from '@shared/ui/share-code/ShareCode' diff --git a/frontend/src/features/device-link/LinkDeviceForm.tsx b/frontend/src/features/device-link/LinkDeviceForm.tsx index e2a50de..00e585f 100644 --- a/frontend/src/features/device-link/LinkDeviceForm.tsx +++ b/frontend/src/features/device-link/LinkDeviceForm.tsx @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query' import { useState, type FormEvent } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { describeError } from '@shared/api/errors' import { Button } from '@shared/ui/Button' import { Notice } from '@shared/ui/Notice' diff --git a/frontend/src/features/download/DownloadButton.tsx b/frontend/src/features/download/DownloadButton.tsx index c277ff7..42c0865 100644 --- a/frontend/src/features/download/DownloadButton.tsx +++ b/frontend/src/features/download/DownloadButton.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { DOWNLOAD_URL, formatSize } from './api' import '../landing/styles/landing.css' diff --git a/frontend/src/features/download/ReleaseNotes.tsx b/frontend/src/features/download/ReleaseNotes.tsx index a245406..a2a3426 100644 --- a/frontend/src/features/download/ReleaseNotes.tsx +++ b/frontend/src/features/download/ReleaseNotes.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Markdown } from '@shared/ui/Markdown' import { fetchReleases } from './api' diff --git a/frontend/src/features/landing/CommandHero.tsx b/frontend/src/features/landing/CommandHero.tsx index 7370beb..42d330e 100644 --- a/frontend/src/features/landing/CommandHero.tsx +++ b/frontend/src/features/landing/CommandHero.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { prefersReducedMotion } from '@shared/motion/reducedMotion' import { ButtonLink } from '@shared/ui/Button' import { useSession } from '../auth/session' diff --git a/frontend/src/features/landing/HowItWorks.tsx b/frontend/src/features/landing/HowItWorks.tsx index a18fcba..47ac722 100644 --- a/frontend/src/features/landing/HowItWorks.tsx +++ b/frontend/src/features/landing/HowItWorks.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import '@features/landing/styles/sections.css' diff --git a/frontend/src/features/landing/effects/VoxelScene.tsx b/frontend/src/features/landing/effects/VoxelScene.tsx index 5330fa9..512390e 100644 --- a/frontend/src/features/landing/effects/VoxelScene.tsx +++ b/frontend/src/features/landing/effects/VoxelScene.tsx @@ -119,15 +119,6 @@ function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) { ctx.fillRect(sx - sun / 2, sy - sun / 2, sun, sun) ctx.fillStyle = '#ffe7b8' ctx.fillRect(sx - sun / 4, sy - sun / 4, sun / 2, sun / 2) - // Blocky clouds. - for (let c = 0; c < 7; c++) { - const cx = rnd() * w - const cy = h * (0.12 + rnd() * 0.3) - const u = 14 - ctx.fillStyle = `rgba(${200 + rnd() * 40}, ${120 + rnd() * 40}, ${170 + rnd() * 40}, 0.2)` - const len = 4 + Math.floor(rnd() * 7) - for (let i = 0; i < len; i++) ctx.fillRect(cx + i * u, cy + (i % 3 === 0 ? -u : 0), u, u * (i % 3 === 0 ? 2 : 1)) - } } /** diff --git a/frontend/src/features/landing/sections/explore/FaqDownload.tsx b/frontend/src/features/landing/sections/explore/FaqDownload.tsx index 48f9952..f9e5f75 100644 --- a/frontend/src/features/landing/sections/explore/FaqDownload.tsx +++ b/frontend/src/features/landing/sections/explore/FaqDownload.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { GITHUB_RELEASES_URL } from '@features/download/api' import '@features/landing/styles/landing.css' diff --git a/frontend/src/features/landing/sections/explore/HudPlayground.tsx b/frontend/src/features/landing/sections/explore/HudPlayground.tsx index 0d3de41..af772cc 100644 --- a/frontend/src/features/landing/sections/explore/HudPlayground.tsx +++ b/frontend/src/features/landing/sections/explore/HudPlayground.tsx @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Button } from '@shared/ui/Button' import { themeToCssVars } from '@features/clickgui/themeVars' import { parseArgb } from '@features/themes/types' @@ -97,7 +97,7 @@ export function HudPlayground() { ref={(el) => { widgetRefs.current[w.id] = el }} - role="button" + role="group" tabIndex={0} title={w.label} aria-label={t('hud.dragAria', { name: w.label })} diff --git a/frontend/src/features/landing/sections/explore/ModuleWall.tsx b/frontend/src/features/landing/sections/explore/ModuleWall.tsx index 1ecb98f..57b8952 100644 --- a/frontend/src/features/landing/sections/explore/ModuleWall.tsx +++ b/frontend/src/features/landing/sections/explore/ModuleWall.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { CATEGORIES } from '@features/clickgui/parts/modules' import '@features/landing/styles/landing.css' diff --git a/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx b/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx index d1a0b2f..e3a3781 100644 --- a/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx +++ b/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { browse } from '@features/showcase/api' import '@features/landing/styles/sections.css' diff --git a/frontend/src/features/landing/sections/hero/Hero.tsx b/frontend/src/features/landing/sections/hero/Hero.tsx index d1d5c79..2e1f694 100644 --- a/frontend/src/features/landing/sections/hero/Hero.tsx +++ b/frontend/src/features/landing/sections/hero/Hero.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useRef } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow' import { PRESETS } from '@features/themes/presets.generated' diff --git a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx index ae6d5e5..40d77ca 100644 --- a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx +++ b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx @@ -1,5 +1,5 @@ import { type MouseEvent, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { ButtonLink } from '@shared/ui/Button' import { prefersReducedMotion } from '@shared/motion/reducedMotion' import { RollText } from '@shared/motion/RollText' diff --git a/frontend/src/features/landing/tests/interactions.test.tsx b/frontend/src/features/landing/tests/interactions.test.tsx index f61486f..9d291e8 100644 --- a/frontend/src/features/landing/tests/interactions.test.tsx +++ b/frontend/src/features/landing/tests/interactions.test.tsx @@ -43,7 +43,7 @@ test('picking a swatch repaints the theme section: pressed state and the big the test('holding a HUD widget switches the screen into edit mode until it is released', () => { reduceMotion() withTheme() - const widget = screen.getByRole('button', { name: /FPS/ }) + const widget = screen.getByRole('group', { name: /FPS/ }) widget.setPointerCapture = () => {} const grid = document.querySelector('.lv-hud-grid') expect(grid).toHaveAttribute('data-on', 'false') @@ -60,11 +60,11 @@ test('holding a HUD widget switches the screen into edit mode until it is releas test('"Put it back" restores the HUD after moving a widget', () => { reduceMotion() withTheme() - const start = screen.getByRole('button', { name: /FPS/ }).style.left - fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' }) - expect(screen.getByRole('button', { name: /FPS/ }).style.left).not.toBe(start) + const start = screen.getByRole('group', { name: /FPS/ }).style.left + fireEvent.keyDown(screen.getByRole('group', { name: /FPS/ }), { key: 'ArrowRight' }) + expect(screen.getByRole('group', { name: /FPS/ }).style.left).not.toBe(start) fireEvent.click(screen.getByRole('button', { name: 'Вернуть как было' })) - expect(screen.getByRole('button', { name: /FPS/ }).style.left).toBe(start) + expect(screen.getByRole('group', { name: /FPS/ }).style.left).toBe(start) }) test('module wall shows how many modules each category has; Visuals has the 52 the headline promises', () => { diff --git a/frontend/src/features/landing/tests/landing.test.tsx b/frontend/src/features/landing/tests/landing.test.tsx index 7faa645..fad3600 100644 --- a/frontend/src/features/landing/tests/landing.test.tsx +++ b/frontend/src/features/landing/tests/landing.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { expect, test, vi } from 'vitest' -import { I18nextProvider } from 'react-i18next' +import { I18nProvider } from '@shared/i18n/I18nProvider' import { CommandHero } from '../CommandHero' import { Hero } from '../sections/hero/Hero' import { createMemoryRouter, RouterProvider } from 'react-router' @@ -29,13 +29,13 @@ test('english locale renders the landing hero headline', async () => { const i18n = initI18n('en') const router = createMemoryRouter([{ path: '/', element: }]) render( - + - , + , ) expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent("you'll fall in love with") }) @@ -49,11 +49,11 @@ test('cloud section speaks in player terms in both languages', async () => { ] as const) { const router = createMemoryRouter([{ path: '/', element: }]) const { unmount } = render( - + - , + , ) expect(await screen.findByRole('heading', { level: 2 })).toHaveTextContent(title) unmount() @@ -66,11 +66,11 @@ test('hero download CTA goes to the page with both download buttons', async () = const router = createMemoryRouter([{ path: '/', element: }]) // Explicit ru: an earlier test in this file re-inits the default instance in English. render( - + - , + , ) const cta = await screen.findByRole('link', { name: 'Скачать бесплатно' }) expect(cta).toHaveAttribute('href', '/download') diff --git a/frontend/src/features/landing/tests/sections.test.tsx b/frontend/src/features/landing/tests/sections.test.tsx index 74c0cba..ecee8fd 100644 --- a/frontend/src/features/landing/tests/sections.test.tsx +++ b/frontend/src/features/landing/tests/sections.test.tsx @@ -26,17 +26,17 @@ afterEach(() => vi.unstubAllGlobals()) test('HudWidget moves by 8px with ArrowRight', () => { reduceMotion() withQuery() - const widget = screen.getByRole('button', { name: /FPS/ }) + const widget = screen.getByRole('group', { name: /FPS/ }) const before = Number.parseInt(widget.style.left, 10) fireEvent.keyDown(widget, { key: 'ArrowRight' }) - expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8) + expect(Number.parseInt(screen.getByRole('group', { name: /FPS/ }).style.left, 10)).toBe(before + 8) }) test('HudWidget stays inside the playfield bounds', () => { reduceMotion() withQuery() - for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' }) - const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10) + for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('group', { name: /FPS/ }), { key: 'ArrowRight' }) + const left = Number.parseInt(screen.getByRole('group', { name: /FPS/ }).style.left, 10) expect(left).toBeGreaterThanOrEqual(0) expect(left).toBeLessThanOrEqual(680) }) diff --git a/frontend/src/features/profile/components/PublicProfileView.tsx b/frontend/src/features/profile/components/PublicProfileView.tsx index cfb272d..43bdab0 100644 --- a/frontend/src/features/profile/components/PublicProfileView.tsx +++ b/frontend/src/features/profile/components/PublicProfileView.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { ApiError } from '@shared/api/client' import { Notice } from '@shared/ui/Notice' import { ListingList } from '@features/showcase/components/ListingList' diff --git a/frontend/src/features/showcase/components/CopyToSlot.tsx b/frontend/src/features/showcase/components/CopyToSlot.tsx index 2554c9e..4a06707 100644 --- a/frontend/src/features/showcase/components/CopyToSlot.tsx +++ b/frontend/src/features/showcase/components/CopyToSlot.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link, useLocation } from 'react-router' import { Notice } from '@shared/ui/Notice' import { ApiError } from '@shared/api/client' diff --git a/frontend/src/features/showcase/components/ListingDetail.tsx b/frontend/src/features/showcase/components/ListingDetail.tsx index 67dabce..ccbd76b 100644 --- a/frontend/src/features/showcase/components/ListingDetail.tsx +++ b/frontend/src/features/showcase/components/ListingDetail.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { Notice } from '@shared/ui/Notice' import { getListing } from '../api' diff --git a/frontend/src/features/showcase/components/ListingItem.tsx b/frontend/src/features/showcase/components/ListingItem.tsx index 4fa3d50..eaf9e93 100644 --- a/frontend/src/features/showcase/components/ListingItem.tsx +++ b/frontend/src/features/showcase/components/ListingItem.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { copiesText } from '../copies' import type { Listing } from '../types' diff --git a/frontend/src/features/showcase/components/ListingList.tsx b/frontend/src/features/showcase/components/ListingList.tsx index 6050c2f..b629e0d 100644 --- a/frontend/src/features/showcase/components/ListingList.tsx +++ b/frontend/src/features/showcase/components/ListingList.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { useSearchParams } from 'react-router' import { Button } from '@shared/ui/Button' import { Notice } from '@shared/ui/Notice' diff --git a/frontend/src/features/showcase/copies.ts b/frontend/src/features/showcase/copies.ts index 640d6b7..86a6d00 100644 --- a/frontend/src/features/showcase/copies.ts +++ b/frontend/src/features/showcase/copies.ts @@ -1,4 +1,4 @@ -import type { TFunction } from 'i18next' +import type { TFunction } from '@shared/i18n/react' const RU_PLURAL = new Intl.PluralRules('ru') diff --git a/frontend/src/features/themes/editor/ThemeEditor.tsx b/frontend/src/features/themes/editor/ThemeEditor.tsx index 0a9fc9f..56a0bc4 100644 --- a/frontend/src/features/themes/editor/ThemeEditor.tsx +++ b/frontend/src/features/themes/editor/ThemeEditor.tsx @@ -1,5 +1,5 @@ import { type ReactNode, startTransition, useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { themeToCssVars } from '@features/clickgui/themeVars' import { PRESETS } from '../presets.generated' import type { ThemeEntry } from '../types' diff --git a/frontend/src/features/themes/editor/fields/ColorField.tsx b/frontend/src/features/themes/editor/fields/ColorField.tsx index ac3db9e..08ea812 100644 --- a/frontend/src/features/themes/editor/fields/ColorField.tsx +++ b/frontend/src/features/themes/editor/fields/ColorField.tsx @@ -1,5 +1,5 @@ import { type CSSProperties, memo, useId, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { argbToCss, parseArgb } from '../../types' import './fields.css' diff --git a/frontend/src/features/themes/editor/fields/GradientField.tsx b/frontend/src/features/themes/editor/fields/GradientField.tsx index afdb438..83987c2 100644 --- a/frontend/src/features/themes/editor/fields/GradientField.tsx +++ b/frontend/src/features/themes/editor/fields/GradientField.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { cssAngle } from '@features/clickgui/themeVars' import { argbToCss, type GradientSpec } from '../../types' import { ColorField } from './ColorField' diff --git a/frontend/src/features/themes/editor/panel/ExportActions.tsx b/frontend/src/features/themes/editor/panel/ExportActions.tsx index 4dbebdd..e6e5259 100644 --- a/frontend/src/features/themes/editor/panel/ExportActions.tsx +++ b/frontend/src/features/themes/editor/panel/ExportActions.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { parseArgb, type ThemeEntry } from '../../types' import { fromProfileJson, toProfileJson, toShareHash } from '../model/codec' diff --git a/frontend/src/features/themes/editor/panel/PaletteTabs.tsx b/frontend/src/features/themes/editor/panel/PaletteTabs.tsx index 0830a90..0208a74 100644 --- a/frontend/src/features/themes/editor/panel/PaletteTabs.tsx +++ b/frontend/src/features/themes/editor/panel/PaletteTabs.tsx @@ -1,5 +1,5 @@ import { type KeyboardEvent, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types' import { ColorField } from '../fields/ColorField' import { GradientField } from '../fields/GradientField' diff --git a/frontend/src/features/themes/editor/panel/PanelHeader.tsx b/frontend/src/features/themes/editor/panel/PanelHeader.tsx index ffc9f7c..3c77b6e 100644 --- a/frontend/src/features/themes/editor/panel/PanelHeader.tsx +++ b/frontend/src/features/themes/editor/panel/PanelHeader.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import type { ThemeEntry } from '../../types' type History = { undo: () => void; redo: () => void; reset: () => void; resetName: string; canUndo: boolean; canRedo: boolean } diff --git a/frontend/src/features/themes/editor/panel/PresetPicker.tsx b/frontend/src/features/themes/editor/panel/PresetPicker.tsx index 53d9ac2..f44226b 100644 --- a/frontend/src/features/themes/editor/panel/PresetPicker.tsx +++ b/frontend/src/features/themes/editor/panel/PresetPicker.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { themeToCssVars } from '@features/clickgui/themeVars' import { PRESETS } from '../../presets.generated' diff --git a/frontend/src/pages/app/ConfigsPage.tsx b/frontend/src/pages/app/ConfigsPage.tsx index 0ec28f5..24bab54 100644 --- a/frontend/src/pages/app/ConfigsPage.tsx +++ b/frontend/src/pages/app/ConfigsPage.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { ParticleField } from '@features/landing/effects/ParticleField' import { VoxelScene } from '@features/landing/effects/VoxelScene' import { SlotGrid } from '@features/configs/components/SlotGrid' diff --git a/frontend/src/pages/app/LinkPage.tsx b/frontend/src/pages/app/LinkPage.tsx index cc3a4a0..bc9b029 100644 --- a/frontend/src/pages/app/LinkPage.tsx +++ b/frontend/src/pages/app/LinkPage.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { LinkDeviceForm } from '@features/device-link/LinkDeviceForm' import { PageTitle } from '@shared/layout/PageTitle' diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx index 7b2a31d..2eaf337 100644 --- a/frontend/src/pages/download/DownloadPage.tsx +++ b/frontend/src/pages/download/DownloadPage.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { DownloadButton } from '@features/download/DownloadButton' import { ReleaseNotes } from '@features/download/ReleaseNotes' import { GITHUB_RELEASES_URL, fetchReleases, releaseSize } from '@features/download/api' diff --git a/frontend/src/pages/public/NotFoundPage.tsx b/frontend/src/pages/public/NotFoundPage.tsx index 4686cf4..737c0ab 100644 --- a/frontend/src/pages/public/NotFoundPage.tsx +++ b/frontend/src/pages/public/NotFoundPage.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { BreakableBlock } from '@shared/easter/BreakableBlock' import { PageTitle } from '@shared/layout/PageTitle' diff --git a/frontend/src/pages/public/auth/AuthScreen.tsx b/frontend/src/pages/public/auth/AuthScreen.tsx index f209493..87955ef 100644 --- a/frontend/src/pages/public/auth/AuthScreen.tsx +++ b/frontend/src/pages/public/auth/AuthScreen.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link, useLocation } from 'react-router' import { Backdrop } from '@features/clickgui/parts/Backdrop' import '@features/clickgui/parts/clickgui.css' diff --git a/frontend/src/pages/public/auth/LoginPage.tsx b/frontend/src/pages/public/auth/LoginPage.tsx index 69998ed..37d2d0d 100644 --- a/frontend/src/pages/public/auth/LoginPage.tsx +++ b/frontend/src/pages/public/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { LoginForm } from '@features/auth/forms/LoginForm' import { AuthScreen } from './AuthScreen' diff --git a/frontend/src/pages/public/auth/RegisterPage.tsx b/frontend/src/pages/public/auth/RegisterPage.tsx index 3b05fbd..750d801 100644 --- a/frontend/src/pages/public/auth/RegisterPage.tsx +++ b/frontend/src/pages/public/auth/RegisterPage.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { RegisterForm } from '@features/auth/forms/RegisterForm' import { AuthScreen } from './AuthScreen' diff --git a/frontend/src/pages/showcase/ShowcasePage.tsx b/frontend/src/pages/showcase/ShowcasePage.tsx index ae1ecbf..797cecc 100644 --- a/frontend/src/pages/showcase/ShowcasePage.tsx +++ b/frontend/src/pages/showcase/ShowcasePage.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { ParticleField } from '@features/landing/effects/ParticleField' import { VoxelScene } from '@features/landing/effects/VoxelScene' import { ListingList } from '@features/showcase/components/ListingList' diff --git a/frontend/src/pages/themes/ThemesPage.tsx b/frontend/src/pages/themes/ThemesPage.tsx index 6d64bcb..f06c17c 100644 --- a/frontend/src/pages/themes/ThemesPage.tsx +++ b/frontend/src/pages/themes/ThemesPage.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { ThemeEditor } from '@features/themes/editor/ThemeEditor' import { fromShareHash } from '@features/themes/editor/model/codec' import { PRESETS } from '@features/themes/presets.generated' diff --git a/frontend/src/shared/api/errors.ts b/frontend/src/shared/api/errors.ts index eda97c4..2ac6938 100644 --- a/frontend/src/shared/api/errors.ts +++ b/frontend/src/shared/api/errors.ts @@ -1,4 +1,4 @@ -import type { TFunction } from 'i18next' +import type { TFunction } from '@shared/i18n/react' import { ApiError } from './client' const BY_STATUS = { diff --git a/frontend/src/shared/easter/BreakableBlock.tsx b/frontend/src/shared/easter/BreakableBlock.tsx index ed04397..7987a55 100644 --- a/frontend/src/shared/easter/BreakableBlock.tsx +++ b/frontend/src/shared/easter/BreakableBlock.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import '@shared/motion/motion.css' import './block.css' diff --git a/frontend/src/shared/easter/KonamiEffect.tsx b/frontend/src/shared/easter/KonamiEffect.tsx index 2230202..afc93ff 100644 --- a/frontend/src/shared/easter/KonamiEffect.tsx +++ b/frontend/src/shared/easter/KonamiEffect.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { useKonami } from './useKonami' export function KonamiEffect() { diff --git a/frontend/src/shared/i18n/I18nProvider.tsx b/frontend/src/shared/i18n/I18nProvider.tsx new file mode 100644 index 0000000..8619fc2 --- /dev/null +++ b/frontend/src/shared/i18n/I18nProvider.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react' +import { I18nContext } from './context' +import type { I18n } from './core' + +/** Scopes an instance to a subtree (tests render the same page in both languages side by side). */ +export function I18nProvider({ i18n, children }: { i18n: I18n; children: ReactNode }) { + return {children} +} diff --git a/frontend/src/shared/i18n/LanguageSwitch.tsx b/frontend/src/shared/i18n/LanguageSwitch.tsx index 3edfa90..58e29bb 100644 --- a/frontend/src/shared/i18n/LanguageSwitch.tsx +++ b/frontend/src/shared/i18n/LanguageSwitch.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' const LANGS = ['ru', 'en'] as const diff --git a/frontend/src/shared/i18n/context.ts b/frontend/src/shared/i18n/context.ts new file mode 100644 index 0000000..1364e51 --- /dev/null +++ b/frontend/src/shared/i18n/context.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react' +import type { I18n } from './core' + +export const I18nContext = createContext(null) + +let current: I18n | null = null + +/** Registers the instance hooks fall back to when no provider is mounted (the app itself uses this). */ +export function setGlobalI18n(instance: I18n): void { + current = instance +} + +export function getI18n(): I18n { + if (!current) throw new Error('i18n is not initialised') + return current +} diff --git a/frontend/src/shared/i18n/core.ts b/frontend/src/shared/i18n/core.ts new file mode 100644 index 0000000..e932995 --- /dev/null +++ b/frontend/src/shared/i18n/core.ts @@ -0,0 +1,124 @@ +/** + * Tiny i18n core (replaces i18next): namespaced dictionaries, `{{var}}` interpolation, + * CLDR plural suffixes (`key_one` / `key_few` / `key_other`), fallback to English. + * Typed keys are derived from the Russian dictionaries (English must match them). + */ + +import type { accountRu } from '@features/account/i18n/ru' +import type { authRu } from '@features/auth/i18n/ru' +import type { clickguiRu } from '@features/clickgui/i18n/ru' +import type { configsRu } from '@features/configs/i18n/ru' +import type { deviceLinkRu } from '@features/device-link/i18n/ru' +import type { downloadRu } from '@features/download/i18n/ru' +import type { landingRu } from '@features/landing/i18n/ru' +import type { profileRu } from '@features/profile/i18n/ru' +import type { showcaseRu } from '@features/showcase/i18n/ru' +import type { themesRu } from '@features/themes/i18n/ru' +import type { commonRu } from '@shared/i18n/common.ru' + +export type Lang = 'ru' | 'en' + +export interface Resources { + common: typeof commonRu + auth: typeof authRu + landing: typeof landingRu + themes: typeof themesRu + clickgui: typeof clickguiRu + download: typeof downloadRu + deviceLink: typeof deviceLinkRu + account: typeof accountRu + configs: typeof configsRu + showcase: typeof showcaseRu + profile: typeof profileRu +} + +export type Namespace = keyof Resources & string + +type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other' +type Paths = T extends string + ? never + : { [K in keyof T & string]: T[K] extends string ? (K extends `${infer B}_${PluralSuffix}` ? B : K) : `${K}.${Paths}` }[keyof T & string] + +export type KeyOf = Paths +type Qualified = { [N in Namespace]: `${N}:${KeyOf}` }[Namespace] + +export type TOptions = { count?: number } & Record +export type TFunction = (key: KeyOf | Qualified, options?: TOptions) => string + +type Dict = { [k: string]: string | Dict } +type Bundles = Record> + +const STORAGE_KEY = 'lv_lang' +const listeners = new Set<() => void>() + +function lookup(dict: Dict | undefined, path: string): string | undefined { + let node: string | Dict | undefined = dict + for (const part of path.split('.')) { + if (node === undefined || typeof node === 'string') return undefined + node = node[part] + } + return typeof node === 'string' ? node : undefined +} + +/** Only an actual `ru` locale gets Russian; every other language falls back to English. */ +export function detectLanguage(): Lang { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved === 'ru' || saved === 'en') return saved + } catch { + // Storage blocked: fall through to the browser language. + } + return typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('ru') ? 'ru' : 'en' +} + +export class I18n { + language: Lang + private readonly bundles: Bundles + + constructor(bundles: Bundles, lng?: Lang) { + this.bundles = bundles + this.language = lng ?? detectLanguage() + document.documentElement.lang = this.language + } + + private resolve(lng: Lang, ns: string, key: string, count?: number): string | undefined { + const dict = this.bundles[lng][ns] + if (count !== undefined) { + const plural = lookup(dict, `${key}_${new Intl.PluralRules(lng).select(count)}`) ?? lookup(dict, `${key}_other`) + if (plural !== undefined) return plural + } + return lookup(dict, key) + } + + getFixedT(lng: Lang, ns: N | readonly N[]): TFunction { + const first = (Array.isArray(ns) ? ns[0] : ns) as string + return ((rawKey: string, options: TOptions = {}) => { + const colon = rawKey.indexOf(':') + const [space, key] = colon === -1 ? [first, rawKey] : [rawKey.slice(0, colon), rawKey.slice(colon + 1)] + const text = this.resolve(lng, space, key, options.count) ?? this.resolve('en', space, key, options.count) + if (text === undefined) return key + return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, name: string) => String(options[name] ?? '')) + }) as TFunction + } + + t: TFunction<'common'> = (key, options) => this.getFixedT(this.language, 'common')(key, options) + + changeLanguage(lng: Lang): Promise { + if (lng !== this.language) { + this.language = lng + document.documentElement.lang = lng + try { + localStorage.setItem(STORAGE_KEY, lng) + } catch { + // Private mode: the choice just lasts for this tab. + } + for (const notify of listeners) notify() + } + return Promise.resolve() + } + + subscribe = (notify: () => void): (() => void) => { + listeners.add(notify) + return () => listeners.delete(notify) + } +} diff --git a/frontend/src/shared/i18n/react.ts b/frontend/src/shared/i18n/react.ts new file mode 100644 index 0000000..c169aa8 --- /dev/null +++ b/frontend/src/shared/i18n/react.ts @@ -0,0 +1,15 @@ +import { useContext, useMemo, useSyncExternalStore } from 'react' +import { getI18n, I18nContext } from './context' +import type { I18n, Namespace, TFunction } from './core' + +export type { Lang, TFunction } from './core' +export { getI18n, setGlobalI18n } from './context' + +/** Returns `t` bound to the first namespace (others via `ns:key`) plus the instance; re-renders on language change. */ +export function useTranslation(ns: N | readonly N[] = 'common' as N): { t: TFunction; i18n: I18n } { + const i18n = useContext(I18nContext) ?? getI18n() + const language = useSyncExternalStore(i18n.subscribe, () => i18n.language) + const first = Array.isArray(ns) ? ns[0] : ns + const t = useMemo(() => i18n.getFixedT(language, first as N), [i18n, language, first]) + return { t: t as TFunction, i18n } +} diff --git a/frontend/src/shared/i18n/tests/core.test.ts b/frontend/src/shared/i18n/tests/core.test.ts new file mode 100644 index 0000000..b3b2a22 --- /dev/null +++ b/frontend/src/shared/i18n/tests/core.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { I18n } from '../core' + +const bundles = { + ru: { common: { hi: 'Привет, {{name}}', only_ru: 'только ру', n_one: '{{count}} шт', n_other: '{{count}} шт.' } }, + en: { common: { hi: 'Hi, {{name}}', n_one: '{{count}} item', n_other: '{{count}} items' } }, +} as never + +afterEach(() => { + localStorage.clear() + vi.unstubAllGlobals() +}) + +test('interpolates variables and picks plural forms', () => { + const i18n = new I18n(bundles, 'en') + const t = i18n.getFixedT('en', 'common' as never) as (k: string, o?: object) => string + expect(t('hi', { name: 'Loki' })).toBe('Hi, Loki') + expect(t('n', { count: 1 })).toBe('1 item') + expect(t('n', { count: 4 })).toBe('4 items') +}) + +test('missing Russian keys fall back to English, unknown keys echo the key', () => { + const i18n = new I18n(bundles, 'ru') + const t = i18n.getFixedT('ru', 'common' as never) as (k: string) => string + expect(t('only_ru')).toBe('только ру') + expect(t('nope')).toBe('nope') +}) + +test('the saved language wins over the browser, and only ru browsers default to Russian', () => { + vi.stubGlobal('navigator', { language: 'ru-RU' }) + expect(new I18n(bundles).language).toBe('ru') + localStorage.setItem('lv_lang', 'en') + expect(new I18n(bundles).language).toBe('en') + localStorage.clear() + vi.stubGlobal('navigator', { language: 'de-DE' }) + expect(new I18n(bundles).language).toBe('en') +}) + +test('changeLanguage persists the choice and notifies subscribers', async () => { + const i18n = new I18n(bundles, 'en') + const seen = vi.fn() + const off = i18n.subscribe(seen) + await i18n.changeLanguage('ru') + expect(seen).toHaveBeenCalledOnce() + expect(localStorage.getItem('lv_lang')).toBe('ru') + expect(document.documentElement.lang).toBe('ru') + off() +}) diff --git a/frontend/src/shared/i18n/tests/i18n.test.tsx b/frontend/src/shared/i18n/tests/i18n.test.tsx index 3df4e50..5e01262 100644 --- a/frontend/src/shared/i18n/tests/i18n.test.tsx +++ b/frontend/src/shared/i18n/tests/i18n.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { I18nextProvider, useTranslation } from 'react-i18next' +import { I18nProvider } from '@shared/i18n/I18nProvider' +import { useTranslation } from '@shared/i18n/react' import { expect, test } from 'vitest' import { initI18n } from '@app/i18n' import { LanguageSwitch } from '../LanguageSwitch' @@ -14,10 +15,10 @@ test('switching language re-renders strings and sets ', async () => { const user = userEvent.setup() const i18n = initI18n('ru') render( - + - , + , ) expect(screen.getByText('Войти')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'EN' })) diff --git a/frontend/src/shared/layout/AppShell.tsx b/frontend/src/shared/layout/AppShell.tsx index 8be8cae..2fc7b7d 100644 --- a/frontend/src/shared/layout/AppShell.tsx +++ b/frontend/src/shared/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Outlet } from 'react-router' import { KonamiEffect } from '../easter/KonamiEffect' import { Footer } from './Footer' diff --git a/frontend/src/shared/layout/Footer.tsx b/frontend/src/shared/layout/Footer.tsx index 4990b12..e168e7a 100644 --- a/frontend/src/shared/layout/Footer.tsx +++ b/frontend/src/shared/layout/Footer.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { GITHUB_REPO } from '@features/download/api' import { LanguageSwitch } from '../i18n/LanguageSwitch' diff --git a/frontend/src/shared/layout/tests/layout.test.tsx b/frontend/src/shared/layout/tests/layout.test.tsx index 2292022..15569e5 100644 --- a/frontend/src/shared/layout/tests/layout.test.tsx +++ b/frontend/src/shared/layout/tests/layout.test.tsx @@ -1,6 +1,6 @@ import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { getI18n } from 'react-i18next' +import { getI18n } from '@shared/i18n/react' import { afterEach, expect, test } from 'vitest' import { routes } from '@app/routes' import { renderApp } from '@test/render' diff --git a/frontend/src/shared/layout/topbar/MobileMenu.tsx b/frontend/src/shared/layout/topbar/MobileMenu.tsx index cdbdbe9..c33f302 100644 --- a/frontend/src/shared/layout/topbar/MobileMenu.tsx +++ b/frontend/src/shared/layout/topbar/MobileMenu.tsx @@ -1,5 +1,5 @@ import { type RefObject, useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { NavLink } from 'react-router' import { usePresence } from '@shared/motion/usePresence' import { LanguageSwitch } from '../../i18n/LanguageSwitch' diff --git a/frontend/src/shared/layout/topbar/NavPill.tsx b/frontend/src/shared/layout/topbar/NavPill.tsx index 5d79ab2..6f6d5b5 100644 --- a/frontend/src/shared/layout/topbar/NavPill.tsx +++ b/frontend/src/shared/layout/topbar/NavPill.tsx @@ -1,5 +1,5 @@ import { useSharedThumb } from '@shared/motion/useSharedThumb' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { NavLink } from 'react-router' export type NavItem = { to: string; label: string } diff --git a/frontend/src/shared/layout/topbar/TopBar.tsx b/frontend/src/shared/layout/topbar/TopBar.tsx index ee0b849..15bf519 100644 --- a/frontend/src/shared/layout/topbar/TopBar.tsx +++ b/frontend/src/shared/layout/topbar/TopBar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link, useLocation } from 'react-router' import { LanguageSwitch } from '../../i18n/LanguageSwitch' import { MobileMenu } from './MobileMenu' diff --git a/frontend/src/shared/layout/topbar/parts.tsx b/frontend/src/shared/layout/topbar/parts.tsx index c6ba15f..38d3a21 100644 --- a/frontend/src/shared/layout/topbar/parts.tsx +++ b/frontend/src/shared/layout/topbar/parts.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Link } from 'react-router' import { useSession } from '@features/auth/session' import type { Me } from '../../types' diff --git a/frontend/src/shared/ui/share-code/ShareCode.tsx b/frontend/src/shared/ui/share-code/ShareCode.tsx index d492863..5c6dda0 100644 --- a/frontend/src/shared/ui/share-code/ShareCode.tsx +++ b/frontend/src/shared/ui/share-code/ShareCode.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@shared/i18n/react' import { Button } from '../Button' export function ShareCode({ code, label }: { code: string; label?: string }) { diff --git a/mod/TODO.md b/mod/TODO.md index dc7e363..b11d876 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -591,3 +591,149 @@ ### Итог: ничего из sacura/GodWeer не даёт принципиально нового attack-эффекта, ### которого нет в LoVisual — расхождение только в деталях полировки (easing-кривые, ### доп. custom-режим свинга, возможный distortion-слой, through-walls toggle). + +--- + +## Фаза 12: Сессия 2026-10-01 — новые визуалы, меню, войсчат, автокомплит, починка запуска + +> Всё за 2026-10-01 лежит ОДНИМ коммитом (владелец просит большие коммиты, не дробить). +> Игру в этой сессии запускал только владелец (`./gradlew runClient`, из каталога `mod/`); +> всё, что ниже помечено «не проверено вживую», видно только в игре. + +### Сделано (код + сборка + тесты зелёные) + +- [x] **TargetESP: режим «Созвездие» (`constellation`).** Звёзды на наклонной орбите вокруг цели, + линии между ними, свечение. Настройки: звёзды 3-24, скорость, орбита, линии, размер, свечение. + Геометрия: `combat/targetesp/constellation/` (чистый `ConstellationLayout` + тест). + `TargetESP.java` сокращён 329 -> 161 строка: отслеживание цели и плавное появление вынесены в + `targetesp/state/TargetEspTracker`, цвет в `TargetEspColors`. +- [x] **JumpCircles переписан** (саб-агент): 7 новых форм (Звезда, Ромб, Спираль, Гексагон, Волна, Крест, + Лепестки) к Круг/Кольцо/Пузырь; общая анимация (`CircleAnimation`), цвета из `AnimatedRenderColors`, + круг рождается на высоте земли в момент отрыва. Пакет `visuals/player/jumpcircles/` (+ `shape/`), + 12 тестов. Удалены настройки `ease_out`, `pulse_max_size`, `pulse_lifetime`. +- [x] **KillEffect: режим «Кровь» (`Blood`)** по архиву killeffect.zip. Капли с физикой, отскоки от стен, + лужи на полу, мелкие брызги; через наш `Renderer3D`. Пакет `hits/blood/`. Настройки: цвет, + количество, сила, размер, время пятен, тип разлёта (Burst/Fountain/Ring/Spray; Spray летит от атакующего). +- [x] **Главное меню: тип «Hero»** по архиву Ya_Lublu_Femboyev.zip (экран Vectra). Настройка `menuLayout` + (`orbs` по умолчанию / `hero`), фабрика `MainMenus`, экран `mainmenu/hero/*`, чистая логика + `hero/logic/*` + тесты. Интеграция: альт-менеджер, `SkinManager`, наши темы. +- [x] **Simple Voice Chat (мягкая совместимость, API 2.6.24).** `compat/voicechat/` (плагин, `VoiceState`, + `VoiceChatBridge`), модуль `VoiceChat` (кольца и свечение над говорящими, крест при заглушённом + микрофоне), HUD-панель `VoiceChat` (список говорящих + статус микрофона). API не попадает в jar. +- [x] **`render/engine/renderer/quick/Draw`** — короткий статический фасад над `Renderer2D.COLOR` + (rect, gradient, outline, glassOutline, shadow, circle) + `GlassEdge` с тестом. + Заметка: у `Renderer2D` короткие перегрузки `roundedRect(x,y,w,h,radius,argb)` уже были, + 6-й параметр в длинной форме это `softness`, а не alpha. +- [x] **Автокомплит `%` в ванильном чате** (как `#` у Baritone): `mixins/gui/chat/CommandSuggestionsMixin` + подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `` + показываются серым текстом (`command/suggest/SuggestionPlan` + тест). BetterChat идёт своим путём. + Раньше подсказки работали только в BetterChat. +- [x] **Починка запуска клиента** (игра не стартовала с августа, все пять дефектов найдены по логам runClient): + 1. MixinExtras 0.5.4 падал на `@Redirect(at=...)` (ClassCastException при трансформации `Entity`): + добавлен `mixinextras-fabric:0.5.5` (`gradle.properties: mixinextras_version`), вшит в jar через `include`. + 2. `ServerPolicyClient`: `registerReceiver` -> `registerGlobalReceiver`. + 3. Классы-помощники лежали в пакете миксинов (Mixin запрещает их загрузку): вынесены в + `render/gamerenderer/` и `mixinhandlers/{player,hud,network}/`, сделаны public. + 4. `Themes`: `STORE` создавался раньше таблицы пресетов (NPE в `ThemeStoreIO`): порядок инициализации. + 5. `MainSettingsComponent.rebuildSettings`: `addAll` в неизменяемый список (падал ClickGUI по RShift). +- [x] 2026-10-01 все сегодняшние коммиты объединены в один по просьбе владельца. + +### Отзывы владельца после запуска (скриншоты) — СДЕЛАНО в коде, ждёт проверки в игре + +Итог 2026-10-01 (сборка и тесты зелёные, визуально не проверено): +- Тултипы: причина была в том, что пользовательский тултип, перекраска, фон и предпросмотр шалкера читали + собственные флаги и игнорировали переключатель модуля; теперь везде `isEnabled()`. Синие строки + категорий красятся акцентом темы (`TooltipColors`). Тема по умолчанию `Themes.THEME_DEFAULT = AMBER` + (сохранённый выбор пользователя не трогается). +- Описания: ~98 новых ru/en (62 модуля, 36 HUD), `HudElement.getDescription()`, тест `DescriptionCoverageTest`. +- ClickGUI: плашка с описанием над колонками (`ModulesMenuDescriptionRenderer`, `DescriptionLines`). +- Hero-меню: раскладка по измеренному тексту (`TextMetrics`, `HeroLayout`), кнопки с SVG-иконками + (`hero/paint/*`), orbs: подпись под кнопкой при наведении (`MainMenuHoverLabel`). +- Настройки меню: новая категория «Главное меню» (`MAIN_MENU`), `MainConfig.getMainMenuSettingDefs()`. +- Проверить в игре: положение и размер плашки описания, вид amber-тултипа, иконки и шрифты Hero-меню, + подпись orbs-кнопок, видимость настроек (`visibleWhen`) в категории «Главное меню». + +Задача А (форк, тултипы и описания): +- [x] **Тултип предметов (BetterTooltips) странный и не выключается.** При первом запуске у камня синие + строки-категории («Building Blocks / Natural Blocks») и тёмная панель; даже при выключенном модуле + стиль остаётся. Нужно: найти причину (миксин `mixins/hud/tooltips/TooltipRenderUtilMixin` + применяет стиль без проверки включённости / кэш `TooltipState`), при выключении тултип строго + ванильный, при включении аккуратный стиль. **Стиль по умолчанию должен быть `amber`, а не `classic`** + (`Themes.THEME_AMBER`); старые конфиги не ломать. +- [x] **Нет описаний модулей и HUD-элементов в настройках.** На всех карточках HUD/UI написано + «Module description is missing.». Нужно добавить описания ru/en для каждого `@ModuleInfo`-модуля и + каждого HUD-элемента (ключи `module..description` и HUD-аналог), проверить, что не осталось + пустых (тест или скрипт сверки id <-> lang). +- [x] **Описание функции при наведении в обычном ClickGUI** (колонки Combat/Player/Visuals/Other). + В области сверху по центру над колонками показывать описание функции под курсором (плашка в нашем + стиле, плавное появление, перенос/обрезка по ширине). + +Задача Б (форк, главное меню): +- [x] **Hero-меню «калово»**: заголовок налезает на подзаголовок и кнопки, нет иконок на кнопках, + мелкие и кривые шрифты. Эталон: референс Vectra (иконки слева в кнопках, акцентная Multiplayer, + тёмное стекло у остальных, пара Options/Quit внизу, карточка аккаунта слева сверху, виджет + времени справа сверху). Раскладку считать по измеренным размерам текста, а не по константам. +- [x] **Основное (orbs) меню тоже улучшить стилем** (типографика, тени, hover, появление). +- [x] **Настройки главного меню разбросаны и спрятаны** (`menuLayout`, `menuBg`, `menuBgUseTheme`, + `menuClockShowSeconds` в `MainConfig`). Сгруппировать в понятный раздел «Главное меню» в + настройках (ключи конфигов не менять, чтобы сохранённые значения не потерялись). + +### Правки после второго запуска (2026-10-01) + +- [x] `%tier `: не было `suggest` (подсказка показывала только серый ``): теперь ники игроков и режимы + (`vanilla|uhc|pot|nethop|smp|sword|axe|mace`). Остальные команды без `suggest` не имеют аргументов. +- [x] KillEffect Blood было видно сквозь блоки: пайплайн `WORLD_COLORED` без depth-теста заменён на `WORLD_COLORED_DEPTH`. +- [x] TargetESP: новый режим `swords` (8 мечей на орбите, пружинная физика, отдача при ударе, подсветка при хите). + `targetesp/sword/` (`SwordPhysics` чистая + 4 теста, `TargetEspSwords` рендер), текстура `particles/sword.png` + нарисована скриптом (пиксель-арт, окрашивается цветом палитры). Мечи рисуются поверх блоков (ESP, без depth). + Не проверено вживую: вид, размер, яркость свечения, текстура меча (при желании заменить на свою). + +### Фаза 13: разбор evo клиента (2026-10-01), план работ + +Отчёт: `mod/docs/EVO_REPORT.md` (29 модулей evo, которых нет у нас; крылья; ножи; HMI/Lua). +Лицензия: `ref/evo/README.md` объявляет код и ассеты проприетарными. Для релиза только свои модели/скрипты +или разрешение автора; формат (Bedrock geo/anim, HMI-Lua) стандартный, код пишем свой. +Порядок по просьбе владельца: сначала 1, потом остальное. + +- [x] 1. HoldMyItems Scripted на LuaJ (пункты 1-8 отчёта, частично): настройка `Режим` = Dynamics / Scripts / Both + (по умолчанию Dynamics, LuaJ грузится лениво). `held/lua/`: `HandScripts` (загрузка + горячая перезагрузка раз + в секунду из `config/lovisual/holdmyitems/{hand_pose,item_pose}.lua`, иначе встроенные примеры в + `assets/lovisual/holdmyitems/`), `HandFrame`; `runtime/`: `LuaSandbox` (только base/bit32/table/string/math, + без io/os/luajava/require/load/debug, лимит инструкций через хук), `LuaPreprocessor` (`global.x = v;` -> registry), + `ScriptSlot` (ошибка выключает только этот скрипт, лог один раз); `api/`: `M` (move/rotate с pivot/scale/push/pop/ + math), `P`, `I`, `Easings` (8 функций), без отдачи Java-объектов в Lua. Хуки в `ItemInHandRendererMixin`: + hand_pose в `submitArmWithItem`, item_pose в `renderItem` (first person). 7 тестов `LuaSandboxTest`. + Не сделано из 1-8: `hand_relative_pose`, аддоны (`*_addon.lua`), подмена ванильного equip/swing скриптом, + обе руки всегда, ItemStack-поля. Не проверено вживую: направление осей M:rotate на реальной руке. +- [ ] 1b. HMI хвост (пункты 9-16): счётчики свинга и события, обе руки, ItemStack-поля, use_duration/using_item, + аниматор частей модели (`item_model.lua`), камера из Lua, Lua-частицы, клавиша осмотра. +- [ ] 2. Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов, общий `BedrockGeoParser` + бейк + сэмплер). +- [ ] 3. Модуль Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person. +- [ ] 4. Быстрые модули: TNTTimer, AutoSprint, TotemCounter, PlaceAnimation, EnchantGlint, ArmorColor, GodRays, ProjectileTrails. +- [ ] 5. DeathEffects, Blizzard, ElytraTrails. + +### Не проверено вживую / открытые вопросы + +- [ ] Проверить в игре автокомплит `%`: ввод `%`, `%to`, Tab; серая подсказка `` после `%toggle `. + Если миксин не применится, прислать лог (поля `CommandSuggestions` сверены по байткоду 26.2). +- [ ] Визуально подогнать: режим «Созвездие», новые формы JumpCircles (ориентация текстур, яркость + аддитивного бленда), кровь (лужи, яркость), Hero/orbs-меню, индикаторы войсчата. +- [ ] Войсчат: события SVC трактованы по javap (`MicrophoneMuteEvent.isDisabled()` как «заглушён или + выключен», `getEntityId()` как UUID игрока); проверить с настоящим SVC 2.6.24; значок шапки HUD-панели + сейчас wifi-глиф (подходящего микрофона в шрифте нет). +- [ ] Тип ClickGUI (идея владельца): селектор «Тип ClickGUI» в настройках + точка выбора экрана + (`ClickGuiScreenManager`, три места `new ClickGuiScreen()`), `classic` по умолчанию, второй вид позже; + бинд и звуки остаются в модуле `ClickGui`. Ждёт решения, какой второй вид делать первым. +- [ ] `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`. +- [ ] Фаза 8.5.3 по-прежнему в очереди (около 150 файлов >200 строк), по мере касания. +- [ ] Приёмка серверного плагина (Фаза 11 в корневом TODO.md): Velocity + `%policy` на стенде. + +### Заметки для следующих сессий + +- Игру запускает владелец: `cd mod && ./gradlew runClient`. Из корня репозитория `./gradlew` не существует. +- Параллельные gradle-сборки блокируют друг друга: форкам запускать через + `flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`. +- Новые классы-помощники для миксинов НЕЛЬЗЯ класть в пакет `dev.loki.lovisual.mixins.*` + (Mixin: `IllegalClassLoadError`); им место в `mixinhandlers/` или `render/`. +- Лимиты проекта: ≤200 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении), + без длинного тире в текстах сайта и ответах. diff --git a/mod/build.gradle b/mod/build.gradle index 3d82636..48aadc9 100644 --- a/mod/build.gradle +++ b/mod/build.gradle @@ -34,6 +34,10 @@ repositories { // ViaBedrock snapshots (alt repo used by Via* stack) url = 'https://maven.lenni0451.net/snapshots' } + maven { + // Simple Voice Chat API (optional compat) + url = 'https://maven.maxhenkel.de/repository/public' + } maven { // JitPack (for com.github.* artifacts like CubeConverter) url = 'https://jitpack.io' @@ -130,6 +134,9 @@ dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" implementation "net.fabricmc:fabric-loader:${project.loader_version}" + // Loader 0.19.4 ships MixinExtras 0.5.4, which crashes on @Redirect(at) arrays with this Mixin; 0.5.5 handles them. + implementation "io.github.llamalad7:mixinextras-fabric:${project.mixinextras_version}" + include "io.github.llamalad7:mixinextras-fabric:${project.mixinextras_version}" implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" @@ -155,6 +162,9 @@ dependencies { runtimeOnly "com.viaversion:viafabricplus:${project.viafabricplus_version}" } + // Simple Voice Chat (optional): client plugin API for speaking indicators; the mod provides it at runtime. + compileOnly "de.maxhenkel.voicechat:voicechat-api:${project.voicechat_api_version}" + // Entity Culling (optional): typed compile-time API for shader ESP visibility compat. compileOnly "maven.modrinth:entityculling:${project.entityculling_version}" @@ -175,6 +185,10 @@ dependencies { // Javet core only. Native V8 binaries are copied into resources/javet/natives. bundledLibrary "com.caoccao.javet:javet:${javetVersion}" + // LuaJ: scripted hand/item poses for HoldMyItems (MIT, pure Java interpreter). + bundledLibrary "org.luaj:luaj-jse:3.0.1" + testImplementation "org.luaj:luaj-jse:3.0.1" + // Javet in minimal V8 mode only: no Node runtime and no ARM native payloads. // ARM builds can be added later behind a separate opt-in distribution profile. javetNativeBundle "com.caoccao.javet:javet-v8-windows-x86_64:${javetVersion}" diff --git a/mod/config/lovisual/hud/draggable/voicechat.json b/mod/config/lovisual/hud/draggable/voicechat.json new file mode 100644 index 0000000..c85d429 --- /dev/null +++ b/mod/config/lovisual/hud/draggable/voicechat.json @@ -0,0 +1,18 @@ +{ + "enabled": false, + "layout": { + "pos": [ + 16.0, + 150.0 + ], + "space": "logical" + }, + "voicechat_scale": 1.0, + "voicechat_layout": "Split Header", + "voicechat_max_rows": 6.0, + "voicechat_hold_ms": 350.0, + "voicechat_show_self": true, + "voicechat_bg_alpha": 131.0, + "voicechat_bg_effect": "Auto", + "voicechat_blur_alpha": 255.0 +} diff --git a/mod/config/lovisual/mainconfig.json5 b/mod/config/lovisual/mainconfig.json5 index fdf4c2c..6de13ab 100644 --- a/mod/config/lovisual/mainconfig.json5 +++ b/mod/config/lovisual/mainconfig.json5 @@ -1,20 +1,12 @@ { 'debug': 'off', - 'forcePvp': false, 'menuBg': 'off', + 'menuLayout': 'orbs', 'menuClockShowSeconds': false, 'menuBgUseTheme': false, 'msaa3d': 'off', 'clickGuiModulesHints': true, 'clickGuiHudEditorHints': true, - 'inventorySwapPolicy': 'none', - 'inventorySwapScope': 'full', - 'inventorySwapVisibility': 'silent', - 'inventorySwapRestore': true, - 'inventorySwapPreferHotbar': true, - 'inventorySwapLegitWaitTicks': 2.0, - 'inventorySwapStrictInventoryWaitTicks': 1.0, - 'inventorySwapStrictMovementLockTicks': 2.0, 'disableNarrator': true, 'backdoorProtection': true, 'backdoorTranslationFilter': true, diff --git a/mod/docs/EVO_REPORT.md b/mod/docs/EVO_REPORT.md new file mode 100644 index 0000000..d0902e2 --- /dev/null +++ b/mod/docs/EVO_REPORT.md @@ -0,0 +1,411 @@ +# Отчёт по клиенту EvoVis (evo) для LoVisual + +Дата: 2026-10-01. Режим: только чтение, в проекте ничего не менялось, кроме этого файла. + +Источники (все пути абсолютные): + +- Деобфусцированный мод evo: `/storage/project/jvm/LoVisual/ref/evo/mod/jar-src-named/evovis/` (читаемые имена классов, 1328 штук) и `/storage/project/jvm/LoVisual/ref/evo/mod/jar-src-plain/sources/defpackage/` (оригинальные короткие имена `afe.java` и т.п.). Исходники ещё с control-flow flattening (`switch (i + i2)`), логику читать можно, но вокруг много шума. +- Ресурсы evo (распакованный jar): `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/` (пространства имён `evolution` и `minecraft`). +- Каталоги модулей: `/storage/project/jvm/LoVisual/ref/evo/analysis/modules.txt` (57 модулей) и `lovisual_modules.txt` (88 наших). +- Таблица соответствия имён: `/storage/project/jvm/LoVisual/ref/evo/analysis/class_map.tsv` (старое имя, новое имя, роль). +- Чистый (не обфусцированный) референс HoldMyItems с Lua: `/storage/project/jvm/LoVisual/ref/clients/mercury-src/com/holdmylua/` и миксины `/storage/project/jvm/LoVisual/ref/clients/mercury-src/fun/mercury/mixin/hmi/` + `HeldItemRendererMixin.java`. +- Наш мод: `/storage/project/jvm/LoVisual/mod/src/main/java/dev/loki/lovisual/`. + +ВАЖНОЕ ЗАМЕЧАНИЕ О ЛИЦЕНЗИИ. `ref/evo/README.md` прямо говорит: код и ассеты EvoVis проприетарные, "в LoVisual ничего не переносим, смотрим подходы и идеи". Поэтому копировать модели/текстуры/звуки/Lua evo в релиз как есть нельзя без разрешения. Форматы там стандартные (Bedrock geo.json + animation.json из Blockbench, Lua-скрипты формата HoldMyItems), так что реализацию можно делать на своих ассетах, а пути ниже нужны как карта и как тестовый материал для локальной отладки. Ниже в разделах 2 и 3 "пути для копирования" означают пути источников, которые нужны на время отладки. Для публичного релиза нужны либо разрешение, либо свои модели в тех же форматах. + +--- + +## 1. Модули evo и чего нет у нас + +Всего у evo 57 модулей (`evovis/module/{builders,combat,cosmetics,misc,render}`), у нас 88. Список наших взят из `@ModuleInfo` в `features/module/modules/*`. + +Обозначения: сложность S (до дня работы), M (несколько дней), L (неделя и больше или нужна новая инфраструктура). Приоритет P1 (делать первым), P2, P3. "LOC" это размер класса модуля в evo (грубая оценка объёма; сам модуль часто тянет ещё `evovis/internal/*`). + +### 1.1 Что у нас УЖЕ есть (аналог найден, переносить не нужно) + +| Модуль evo | Наш аналог | +|---|---| +| Crosshair | `visuals/player/Crosshair` | +| HitBox | `visuals/visibility/Hitboxes` | +| Predictions | `combat/prediction/Predictions` | +| AspectRatio | `visuals/camera/AspectRatio` | +| BetterMinecraft | `misc/vanilla/BetterMinecraft` | +| BlockOverlay | `visuals/scene/BlockHighlight` (у evo есть ещё шейдерные режимы, см. 1.3) | +| Chams | `visuals/scene/visual/Chams` | +| HMI | `visuals/items/held/HoldMyItems` (урезанный аналог, раздел 4) | +| JumpCircles | `visuals/player/JumpCircles` | +| MotionBlur | `visuals/fx/MotionBlur` | +| NameTags | `visuals/nametags/NameTags` | +| TargetESP | `combat/prediction/TargetESP` | +| Trails | `visuals/effects/ambient/Trails` | +| WorldLyrics | `visuals/lyrics/KineticLyrics` | +| WorldTweaks | `visuals/weather/WorldTweaks` | +| Zoom | `visuals/fx/Zoom` | +| ItemScroller | `misc/items/ItemScroller` | +| NoRender | `visuals/visibility/NoRender` | +| Menu / Interface | `misc/ClickGui` и HUD-система `features/gui/hud/*` | +| BetterRain | `visuals/weather/precip/Rain`, `WetWorld` | +| FireFly | `visuals/particles/AmbientParticles` (там есть firefly) | +| Particles (на удар, тотем) | `visuals/effects/hits/HitEffect`, `visuals/fx/TotemFX` | +| Waypoints | частично: `visuals/world/Gps` + Xaero-совместимость `compat/xaero/*` | + +### 1.2 Чего у нас НЕТ (полный список, 29 модулей) + +| Категория | Модуль evo | Суть | LOC | Сложность | Приоритет | +|---|---|---|---|---|---| +| BUILDERS | Chunk Cache | держит посещённые чанки загруженными за пределами server view distance | 314 | M/L (клиентский ChunkCache, совместимость с Sodium) | P3 | +| BUILDERS | Schematics | призрачный предпросмотр и список материалов для litematic/schem/nbt | 1258 | L | P3 | +| BUILDERS | Showcase | находит постройку, летит кинематографичным путём вокруг и сохраняет клип | 882 | L (камера-путь это M, запись клипа это L) | P2 | +| COMBAT | HitFix | атаки по хитбоксу, который реально видишь, а не на тик вперёд | 665 | M (трогает сетевую логику атаки, риск античита) | P3 | +| COMBAT | TNTTimer | остаток фитиля у зажжённого TNT | 324 | S | P1 | +| COSMETICS | Cosmetics | 3D-косметика и плащи на игроках (крылья, рюкзаки, скины-модели, плащ) | 46 + `internal/Cosmetic*` | L в целом, M только для крыльев (раздел 2) | P1 | +| COSMETICS | Melee Skins | анимированные скины (ножи) на удерживаемых мечах | 860 | L (раздел 3) | P1 | +| COSMETICS | Camera | клиентская камера и штатив: кадр, подбег, фото | 1082 | M/L | P3 | +| COSMETICS | Graffiti | зажать клавишу и наспреить граффити на блок | 1010 | M | P3 | +| COSMETICS | PatPat | пустая рука на мобе, зажать клавишу, погладить | 645 | M | P2 | +| COSMETICS | Skin Wheel | поднять запястный циферблат (Omnitrix), крутить скины со вспышкой | 341 | M (нужны полные модели скинов, `cosmetic/skins`) | P3 | +| MISC | AutoSprint | держит спринт, когда разрешено | 19 | S (у нас уже есть `SprintControlEvent`) | P1 | +| MISC | TotemCounter | счётчик сломанных тотемов и своих | 255 | S | P1 | +| MISC | TotemSound | свой звук при срабатывании тотема | 257 | S | P2 | +| MISC | Sounds | звук на каждое клиентское действие | 195 | S | P2 | +| MISC | InventorySort | сортировка инвентаря по пресету | 390 | S/M | P2 | +| MISC | StorageTracker | запоминает содержимое контейнеров, ищет предметы | 678 | M | P2 | +| MISC | Discord RPC | статус в Discord | 56 | S (но нужна IPC-библиотека, зависимость) | P3 | +| MISC | Replay | хранит последние секунды геймплея, пишет клипы, тримминг | 526 + jcodec, `Evoclip` | L | P3 | +| MISC | Trainer | тренировочные дрели: прицел, тотем, якорь | 402 | M | P3 | +| RENDER | ArmorColor | красит надетую броню по остаточной прочности | 353 | S/M (у нас есть `EquipmentLayerRendererMixin`) | P2 | +| RENDER | Blizzard | высотная метель: ветровые снежинки, белая пелена, обмерзание краёв | 1290 | M/L | P2 | +| RENDER | ConsumeESP | круг с кольцом прогресса, что цель ест или пьёт | 671 | M | P2 | +| RENDER | CustomHand | пост-процесс шейдер руки | 373 | M (у нас есть `shaders/hand_glass.frag`, частично) | P2 | +| RENDER | DeathEffects | игроки рассыпаются на части тела при смерти | 2789 | L | P1 (одна из самых эффектных) | +| RENDER | DynamicLights | факелы и лампы в руке светят по-настоящему | 553 | M (проверить, что наш `Light` это не оно) | P2 | +| RENDER | ElytraTrails | светящиеся шлейфы с концов крыльев элитры | 2208 | M (переиспользовать наш `Trails`) | P2 | +| RENDER | EnchantGlint | замена блеска зачарования стилями | 274 | S/M | P1 | +| RENDER | FoodInfo | сытость, восстановление, подсказка оптимального порядка еды на полосе голода | 889 | M | P2 | +| RENDER | GodRays | лучи солнца сквозь листву и рельеф | 33 + шейдер `shaders/core/godrays` | M | P1 | +| RENDER | GuiPresence | показывает меню Evolution/YouTube, которое держат другие пользователи | 1577 | L (нужен бэкенд evo) | пропустить | +| RENDER | PlaceAnimation | поставленные блоки плавно скользят и наклоняются на место | 195 | S/M | P1 | +| RENDER | ProjectileTrails | гаснущие шлейфы за стрелами, жемчугом и прочими снарядами | 1401 | M (переиспользовать `Trails`) | P1 | + +Итого отсутствует 29 из 57. Из них 3 из BUILDERS, 6 из COSMETICS, 12 из MISC (включая S-модули), остальное из RENDER и COMBAT. + +### 1.3 Самое крутое (рекомендуемый порядок) + +1. **Melee Skins (ножи)**: 33 скина, полные анимации draw/inspect/атаки/бег, звуки CS2-стиля, отдельный шейдер "holy". Раздел 3. +2. **Крылья из моделей** (14 видов, часть с анимацией). У нас уже есть модуль Wings, нужно добавить режим моделей. Раздел 2. +3. **DeathEffects**: распад тела на части (в evo есть шейдер `assets/evolution/shaders/core/death_blast`). +4. **GodRays**: готовые референс-шейдеры в `assets/evolution/shaders/core/godrays/`. +5. **Blizzard** и **ElytraTrails/ProjectileTrails**: хорошо ложатся на наш рендер-движок (`Renderer3D`, `MeshBuilder`). +6. **PlaceAnimation**, **EnchantGlint**, **ArmorColor**: маленькие, дают заметный визуальный эффект. +7. **Showcase** + **Camera** (кинематографика), **PatPat**, **Skin Wheel**, **Graffiti** (социальные косметики). +8. Дополнительно для существующего BlockHighlight: шейдерные режимы оверлея блока у evo (`assets/evolution/shaders/core/blockoverlay/{starfield,holy,plasma,nebula,cobweb}`). + +Прочие ресурсы evo, которые могут пригодиться как референс: `assets/evolution/shaders/core/*` (liquid_glass, kawase_up/down blur, motionblur, godrays, hand, hand_smoke, rain, sky_*, customsky, chams_*, wetworld, trail_glass), `assets/evolution/fonts/msdf/`, `assets/evolution/textures/{jumpcircles,targetesp,mob_effect,particle}`, `assets/evolution/videos/menu_bg.mp4`. + +--- + +## 2. Крылья (wings) + +### 2.1 Где лежат и в каком формате + +Формат: Bedrock Blockbench. Модель это `*.geo.json` (корень `minecraft:geometry`, `format_version` 1.12.0, у `easter` 1.21.0), анимация это `*.animation.json` (Bedrock animation, линейные keyframe, без Molang). Форматов obj/bbmodel/Geckolib у крыльев нет, свой парсер. + +Каталоги (источник): + +- Модели и анимации: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/wings/` (13 geo, 10 animation, всего 340 КБ). +- Текстуры: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/textures/cosmetic/wings/` (14 png, 88 КБ). + +Точные файлы: + +geo.json: `angel`, `blue_dragon`, `dark`, `demon`, `dragon`, `easter`, `ghoul`, `pulse`, `rocker`, `spider`, `trident`, `vulcano`, `wota`. + +animation.json: `blue_dragon`, `dark`, `easter`, `ghoul`, `pulse`, `rocker`, `spider`, `trident`, `vulcano`, `wota`. У `angel`, `demon`, `dragon` анимации в файлах нет, они процедурные в коде (см. 2.3). + +png: `angel_white`, `baby_dragon_white`, `blue_dragon`, `dark`, `demon_black`, `demon_red`, `easter`, `ghoul`, `pulse`, `rocker`, `spider`, `trident`, `vulcano`, `wota`. + +### 2.2 Каталог: 14 видов + +Каталог задан статически в `evovis/hud/WingsHud.java` (оригинал `defpackage/afe.java`), блок `static { WINGS.add(new Cosmetic5(key, name, DragonMode)) }`. Параметры привязки заданы в `evovis/internal/DragonMode.java` (оригинал `afc.java`): scale, offsetY, offsetZ. Для режимов без явных значений: scale 1.0, offsetY 0.45, offsetZ 0.0. + +| key (id) | Название | geo | Текстура (размер png) | Анимация | scale / offsetY / offsetZ | +|---|---|---|---|---|---| +| baby_dragon_white | Baby Dragon (White) | dragon | baby_dragon_white.png (64x64) | процедурная `dragon_wings` | 1.0 / 0.45 / 0 | +| angel_white | Angel (White) | angel | angel_white.png (516x774) | процедурная `angel_wings` | 1.0 / 0.45 / 0 | +| demon_red | Demon (Red) | demon | demon_red.png (516x774) | процедурная `demon_wings` | 1.0 / 0.45 / 0 | +| demon_black | Demon (Black) | demon | demon_black.png (516x774) | процедурная `demon_wings` | 1.0 / 0.45 / 0 | +| pulse | Pulse Wings | pulse | pulse.png (32x32) | pulse.animation.json (idle, 3 с, 4 кости) | 1.0 / -1.2 / 0 | +| dark | Dark Wings | dark | dark.png (64x64) | dark.animation.json (idle, 4 с, 6 костей) | 0.9 / -1.05 / 0.05 | +| ghoul | Ghoul Wings | ghoul | ghoul.png (64x64) | ghoul.animation.json (Idle, 3 с, 21 кость) | 0.6 / -0.65 / 0 | +| rocker | Rocker Wings | rocker | rocker.png (128x128) | rocker.animation.json (main, 2.4 с, 25 костей) | 0.9 / -1.05 / 0 | +| wota | Wota Wings | wota | wota.png (64x640, 10 кадров по 64) | wota.animation.json (idle, 3 с, 8 костей) | 0.9 / -1.05 / 0 | +| trident | Trident Wings | trident | trident.png (32x32) | trident.animation.json (idle, 3.2 с, 1 кость) | 0.9 / -1.05 / 0.2 | +| easter | Easter Wings | easter | easter.png (128x128) | easter.animation.json (idle, 3 с, 19 костей) | 0.9 / -1.05 / 0 | +| blue_dragon | Dragon Wings (Blue) | blue_dragon | blue_dragon.png (128x128) | blue_dragon.animation.json (idle, 1.8 с, 5 костей) | 0.9 / -1.05 / 0 | +| spider | Spider Wings | spider | spider.png (256x256) | spider.animation.json (idle, 2.4 с, 5 костей) | 1.0 / 0 / 0 | +| vulcano | Vulcano Wings | vulcano | vulcano.png (64x64) | vulcano.animation.json (idle, 1.54 с, 4 кости) | 1.0 / 0 / 0 | + +Размер png не равен `texture_width/height` в geo (текстуры в 2x выше). UV в geo задаются в пикселях геометрии, поэтому нормировать надо делением на `description.texture_width/texture_height` из geo, а не на размер png. + +Число костей/кубов по geo: angel 15/12, blue_dragon 8/52, dark 9/14, demon 7/14, dragon 5/12, easter 45/158, ghoul 25/28, pulse 7/12, rocker 58/186, spider 11/94, trident 1/5, vulcano 4/14, wota 21/50. + +Особый случай `wota`: анимированная текстура. `wota.png` это 10 кадров по 64 px высотой (64x640), смена кадра каждые 150 мс (`WOTA_FRAME_HEIGHT = 64`, `WOTA_FRAME_MILLIS = 150`, регистрация через `Animated.register` в `evovis/internal/Animated.java`, `Cosmetic5.getTexture()` дёргает `Animated.tick()`). У нас проще: брать смещение V = frame * (64/640). + +Запасные режимы `AVIAN` и `INSECTOID` в `DragonMode` в каталог не попали, это встроенные JSON-анимации-заглушки в `WingsHud` (`AVIAN_ANIM`, `INSECTOID_ANIM`) для кастомных моделей. Нам не нужны. + +### 2.3 Как рендерятся + +Цепочка в evo: + +1. Каталог и загрузка JSON: `evovis/hud/WingsHud.java` (`afe`). `geometry(mode)` читает `/assets/evolution/cosmetic/wings/.geo.json` из classpath, `bundledAnimation(mode)` читает `.animation.json`. Для `DRACONIC/ANGELIC/DEMONIC` вместо файла подставляется `{"procedural":"dragon_wings"}` и т.п. +2. Экипировка: `WingsHud.applyKey(playerName, key)` собирает JSON "косметики" (`{"name":"wing_","id":..., "attachment":1,"scale":..,"offset":[0,offsetY,offsetZ],"rotation":[0,0,0],"animation":..,"model":}`) и регистрирует через `Internal34.registerWithTexture(json, textureId)`. `attachment: 1` это `AttachmentMode.BODY` (`evovis/internal/AttachmentMode.java`: FREE, BODY, HEAD, ABOVE_HEAD, RIGHT_ARM, LEFT_ARM, RIGHT_LEG, LEFT_LEG, FULL_BODY). Менеджер косметики: `evovis/internal/Internal34.java` (`adq`), дескриптор: `Internal35.java` (`adr`). +3. Слой рендера игрока: `evovis/internal/Internal33.java` (`adp`), `RenderLayer`. Для каждой косметики вызывает `Controller.render(...)`, пропускает невидимых игроков. +4. Позиционирование: `evovis/internal/Controller.java`. Порядок: `bind(poseStack)`, `push`, привязка к части тела `applyAttachment` (для BODY: translate+rotate по `playerModel.body`, поэтому крылья следуют за наклоном при присяде; возвращает сдвиг -0.3), затем `rotateZ(180)`, `translate(offX, offY + attachmentShift, offZ + backOffset)`, `rotateY/X/Z(косметика)`, `scale(scale)`. `backOffset` отодвигает крылья от спины при надетой броне: 0.078125 для нагрудника, 0.1375 для крыльев поверх нагрудника (`WINGS_BACK_OFFSET`). Крылья скрываются при глайдере (элитре) на груди (`DataComponents.GLIDER`). +5. Геометрия: `evovis/hud/ModelRendererHud.java` (`adv`). `render()` вызывает `submitNodeCollector.submitCustomGeometry(poseStack, RenderTypes.entityTranslucent(texture), ...)`, рекурсивно `renderPart(bone)`: translate offset, translate pivot, rotate, scale, translate pivot back, затем кубы (`renderCube`: на каждую из 6 граней по 4 вершины с позицией, UV, нормалью, `OverlayTexture.NO_OVERLAY`, light игрока). +6. Парсер геометрии: `evovis/internal/Rotation2.java` (`aid`), `Definition.java` (`aif`: textureWidth/Height, roots), `Parent.java` (`aig`: кость, pivot, rot, offset, scale, cubes, children). + +Правила парсинга Bedrock geo в `Rotation2` (их надо воспроизвести): + +- Кость: `pivot` читается как `(-x, y, z)`. `rotation` как `(rad(-rx), rad(-ry), rad(rz))`. Кубы внутри кости: `origin`, `size`, `pivot` (по умолчанию = origin), `rotation`, `inflate`, `mirror`; тот же поворот знаков. +- UV куба: либо box-UV массив `[u,v]` (стандартная раскладка Bedrock), либо per-face объект `{north:{uv,uv_size,uv_rotation},...}`; `uv_rotation` допустим только 0/90/180/270. Есть эвристика `collectFrontRects` для плоских кубов нулевой толщины (dedupe прямоугольников "south" против "north"). +- Размеры в пикселях, масштаб 1/16 блока. + +Анимации: + +- Файловые (`*.animation.json`): корень либо `{"animations":{...}}`, либо плоский `{"idle":{...}}` (так у dark, pulse, trident, vulcano, wota; а у blue_dragon, easter, spider корень плоский, но имя клипа `animation.xxx.idle`). Берётся первый клип. Каналы: `rotation`, `position`, `scale`, значения `time -> [x,y,z]`, интерполяция линейная (`ModelRendererHud.interpolate`). Время: `(System.currentTimeMillis() - start) / 1000 % animation_length`. Применение (`ModelRendererHud.applyAnimation`): `rotX = base + rad(-x)`, `rotY = base + rad(-y)`, `rotZ = base + rad(z)`; `offset = base + position`; `scale = base * scale`. Базовые позы костей снимаются один раз (`snapshotBasePose`) и перед каждым кадром восстанавливаются (`resetPart`). +- Процедурные (angel, dragon, demon): `ModelRendererHud.applyProceduralAnimation` выбирает по имени: `angel_wings` это `Internal29` (`defpackage/adg.java`), `dragon_wings` это `Internal42` (`aei.java`), `demon_wings` это `Internal41` (`aeh.java`). Время `lifeTime = ((tickCount + partialTick) / 20) % 360`. Внутри обычные `cos/sin` от градусов на костях: angel (Left_Wing, Right_Wing, bone2..bone11; корневой масштаб 0.6), demon (Wing2, Wing3, bone2, bone3, bone8, bone9; множитель времени 200), dragon (left_wing, right_wing, left_tip, right_tip; частоты 120/300/600/2200). Формулы лежат в этих трёх классах в явном виде. + +Вывод: у evo крылья не зависят от позы игрока (нет режима полёта/присяда, кроме следования за телом). Анимация общая по часам, а не по игроку. + +### 2.4 Наш модуль Wings и как добавить режим "модель из evo" + +Наш модуль: `/storage/project/jvm/LoVisual/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/` + +- `Wings.java` (`@ModuleInfo(id="wings", category=ACCESSORIES)`): настройки `wingsType` (ModeValue из `WingShapes.TYPES`: Angelic, Dragon, Butterfly, Phoenix, Crystal, Mechanical, Fairy, Demon), target (Self/Friends/All Players), scale, flapping, flapStrength, flapSpeed, throughWalls, glow, outline, ribs, color. Фаза `WorldPhase.END_MAIN`, метод `onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta)`. Свой игрок показывается только не от первого лица и без элитры. +- `WingsRenderer.java`: рисует мировые вееры треугольников через `Renderer3D.batch(...)` и joml-матрицу `translate(player) · rotY(180-bodyYaw) · pre-translate · rotX(pitch) · rotZ(roll) · translate(anchor) · scale · [сторона]...`. Есть `resolvePose` (idle/sneak/glide). +- `WingShapes.java`: данные 8 силуэтов и пресеты поз (`POSE_IDLE`, `POSE_SNEAK`, `glidePose`). +- Тесты: `src/test/java/.../wings/WingShapesTest.java`, `WingsRendererTransformTest.java`. + +Текстурный рендер в нашем движке уже есть и ровно подходит: `Renderer3D.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_DEPTH | WORLD_TEXTURED, Identifier texture, Renderer3D.DepthMode.MAIN | NONE)` и запись вершин `mesh.vec3(x,y,z).vec2(u,v).color(r,g,b,a).next()`, затем `mesh.quad(i1,i2,i3,i4)`. Пример: `features/module/modules/visuals/trollface/TrollfaceMask.java` строки 125-132. Текстуры берутся по `Identifier` (ресурс-пак `assets/lovisual/textures/...`). Значит миксин на слой игрока (как `Internal33` у evo) не нужен: можно трансформировать вершины на CPU так же, как это делает `WingsRenderer.put()` и `transformPosition()`. + +План (режим "модель"): + +1. Ассеты. Положить свои/разрешённые файлы в: + - `src/main/resources/assets/lovisual/wings/.geo.json` и `.animation.json`; + - `src/main/resources/assets/lovisual/textures/wings/.png`. + Для отладки можно скопировать (только локально) из: + `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/wings/{angel,blue_dragon,dark,demon,dragon,easter,ghoul,pulse,rocker,spider,trident,vulcano,wota}.geo.json`, + `.../cosmetic/wings/{blue_dragon,dark,easter,ghoul,pulse,rocker,spider,trident,vulcano,wota}.animation.json`, + `.../textures/cosmetic/wings/{angel_white,baby_dragon_white,blue_dragon,dark,demon_black,demon_red,easter,ghoul,pulse,rocker,spider,trident,vulcano,wota}.png`. +2. Новые классы в пакете `wings`: + - `WingModelDef` (record: key, displayName, geoKey, texture Identifier, animKey или procedural id, scale, offsetY, offsetZ). Таблица 2.2 переносится в статический каталог. + - `BedrockGeoParser` (аналог `Rotation2`/`Definition`/`Parent`): читает `minecraft:geometry[0]`, строит дерево костей `Bone{name,parent,pivot,rot,cubes}`. Знаки pivot/rotation как в 2.3. Запекает кубы в массивы позиций и UV (нормировка на `texture_width/height` из description). + - `BedrockAnimation` (аналог `ModelRendererHud.applyAnimation` и `interpolate`): разбор обоих корней (`animations` и плоский), линейные keyframe, `loop`. + - `ProceduralWingAnims` (angel/demon/dragon): три функции `apply(Map, float timeSeconds)` с формулами из `Internal29/41/42`. + - Реестр с кэшем, ленивая загрузка через `Minecraft.getInstance().getResourceManager().getResource(Identifier.fromNamespaceAndPath("lovisual", "wings/x.geo.json"))`. +3. Рендер. В `WingsRenderer` добавить `renderModel(renderer, player, tickDelta, bodyYaw, def, style)`: + - Якорь: вызывать ту же цепочку матриц, что `drawWingSide` (translate player, rotY(180-bodyYaw), pre-translate/pitch от `resolvePose`), затем масштаб `def.scale`. По evo: начало координат модели лежит на `1.5 (шея) + offsetY - 0.3` блока над ногами (у режимов с offsetY около -1.05 это почти уровень ног, потому что координаты geo заданы от ног; у angel/dragon/demon с offsetY 0.45 это уровень шеи, кости ниже pivot). Z-сдвиг: `offsetZ + backOffset` (0.078125 при нагруднике, 0.1375 при крыльях и нагруднике) в сторону от спины. Знак по Z и направление взгляда модели подобрать на практике одним тестом (в тестах `WingsRendererTransformTest` есть пример проверки матриц). + - Обход дерева костей: матрица кости = `translate(offset) · translate(pivot) · rotZYX(rot) · scale · translate(-pivot)`, в блоках (деление на 16). Куб рисовать 6 гранями, вершины через `transformPosition` и `batchTextured(WORLD_TEXTURED_DEPTH, texture, DepthMode.MAIN)`; для throughWalls брать `WORLD_TEXTURED` с `DepthMode.NONE`. + - Время анимации: лучше `(player.tickCount + tickDelta) / 20` (у evo wall-clock, одинаково у всех игроков). + - Свет: наш мировой пайплайн не знает lightmap. Умножить цвет вершины на яркость позиции игрока (`level.getBrightness`) или оставить `fullbright` как опцию, чтобы ночью крылья не светились. + - Анимированная текстура wota: смещение V кадра. + - Для режима glide и sneak взять существующую `WingShapes.glidePose/POSE_SNEAK` (повернуть всю модель по pitch, как сейчас), у evo этого нет, это наша добавка. +4. Настройки в `Wings.java`: добавить `ModeValue wingSource` ("Procedural" / "Model") и второй `ModeValue wingModel` (14 ключей из 2.2) с `visibleWhen(..., () -> "Model".equals(wingSource.get()))`. Прежний `wingType` показывать только для "Procedural". Цвет и glow для режима "модель" можно применять как tint (`color`) и дополнительным аддитивным проходом через `WORLD_TEXTURED_ADDITIVE`. Локализация: `assets/lovisual/lang/{en_us,ru_ru}.json` (в проекте есть `DescriptionCoverageTest`, ключи описаний обязательны). +5. Тесты: парсер на мини-geo, проверка знаков pivot/rotation, нормировки UV, интерполяции keyframe (по образцу `WingShapesTest`). + +--- + +## 3. Ножи (knife, модуль Melee Skins) + +### 3.1 Что это в evo + +Модуль `evovis/module/cosmetics/MeleeSkinsModule.java` (`sz.java`, `@ModuleInfo(name="Melee Skins")`). Подменяет любой меч в руке (`ItemTags.SWORDS`, условие `appliesTo`) на выбранный "скин" (на самом деле полную 3D-модель ножа с анимированными руками). Настройки: `firstPersonSetting` (в своём виде), `thirdPersonSetting` (на теле), `inspectSetting` (клавиша осмотра, `OpenKeySetting`). Выбор скина хранится через `Melee3.equip/applyKey` и сохраняется в профиль косметики (`Cosmetic.persistIfLocal`), в нашем случае достаточно локального конфига. + +### 3.2 Ресурсы (источник) + +- Каталог моделей/анимаций: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (5.7 МБ). + - `index.json` каталог скинов (33 записи, 16.5 КБ). + - `.geo.json` модель (Bedrock geo, `format_version` 1.12.0) и `.anim.json` анимации (Bedrock animation `format_version` 1.8.0) на 21 геометрию (скины-варианты используют общую геометрию через поле `model`). + - Геометрии: `annihil, bayonet, butterfly, cobra_vanguard, css, executor, karambit, m9, mercy, null_annihil, polaris, push, quenching, shadowkiller, silencefd, skeleton, stiletto, tactical, talon, thieve_sea, treasure_kn`. +- Текстуры: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/textures/cosmetic/melee/.png` (3.5 МБ; размеры от 128 до 2048 px; нормировка UV по `texture_width/height` из geo) и иконки слотов `.../textures/cosmetic/melee/slot/.png` (33 штуки). Служебные `m9.png.ppm` и `m9amethyst.png.ppm` (786 КБ каждый) в мод не нужны. +- Звуки: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/sounds/melee/` (94 ogg, 7.6 МБ), подпапки `annihil, cobra_vanguard, cs2, executor, mercy, polaris, quenching, shadowkiller, silencefd, thieve_sea, treasure_kn` (`val` пустая). Регистрация звуков: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/sounds.json`, ключи `melee.<папка>.<имя>` (94 события; `melee.cs2.knife_slash1` -> `evolution:melee/cs2/knife_slash1`). Событие в анимации задаётся в `sound_effects` как `{"0.0":{"effect":"melee.cs2.knife_deploy1"}}`. +- Шейдер "holy": `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/shaders/core/melee_holy.vsh` и `melee_holy.fsh` (GLSL 330, шумовое поле fbm, дрейф, градиент; формат вершин ENTITY, Sampler0). + +### 3.3 Все скины (из `index.json`) + +Базовые (21 геометрия, `key` = имя файла): annihil "Dark Star" (варианты angel/devil, 16 клипов), cobra_vanguard "Cobra Vanguard", executor "Executor", mercy "Mercy", null_annihil "\"Free Dark Star\"", polaris "Desmoulins' Edge", quenching "Dragon's Fang", shadowkiller "Fiery Owl", silencefd "TAC Dagger", thieve_sea "Sea Gleam", treasure_kn "Creed", bayonet "Bayonet", butterfly "Butterfly", css "Classic Knife", karambit "Karambit", m9 "M9 Bayonet", push "Push", skeleton "Skeleton", stiletto "Stiletto", tactical "Huntsman", talon "Talon". + +Перекраски (общая геометрия, поле `model` в index.json): bayoauto "Bayonet | Autotronic" (bayonet), buemerald "Butterfly | Emerald" и busnowfall "Butterfly | Snowfall" (butterfly), karobsidian "Karambit | Obsidian" (karambit), m9auto, m9amethyst, m9emerald, m9sapphire, m9holy (m9; `holyBones:["blade"]`), stilettosapphire, stilettott "Tiger tooth", stilettoholy (stiletto; `holyBones:["b"]`). + +Итого 33 записи (21 базовая + 12 перекрасок). Текстуры у каждой своя `textures/cosmetic/melee/.png`. Поля записи index.json: `key`, `name`, `model` (общая геометрия, опц.), `clips` (список клипов), `transforms` (`thirdperson_righthand`, `thirdperson_lefthand`, `ground`, `fixed`, `gui`, `head`: `rotation/translation/scale` для разных display-контекстов), `variants` (только annihil: `[{suffix:"",hidden:["angel"]},{suffix:"_angel",hidden:["devil"]}]`, скрытие костей и суффикс клипов), `holyBones` (кости, к которым применяется шейдер holy). + +### 3.4 Структура модели и клипов + +Кости, общие для всех ножей (пример karambit, m9, bayonet): `root > bone > knife_and_right > > weapon > ...`, `righthand`, `righthand_pos`, `lefthand`, `lefthand_pos`, `constraint`, а также служебные: `camera`, `view`, `refit_view`, `iron_view`, `idle_view`, `positioning > {ground, thirdperson_hand, fixed}`. Служебные имена, которые читает код (`evovis/internal/Model2.java`): `righthand_pos`, `lefthand_pos` (куда ставить руки игрока), `camera` (покачивание камеры), `idle_view` (якорь вида первого лица), `thirdperson_hand` (якорь в третьем лице). + +Клипы (набор варьируется): `draw`, `draw_1` (случайный выбор варианта), `inspect`, `inspect_1`, `melee_1`, `melee_2`, `melee_3`, `static_idle`, `idle`, `run`, `run_start`, `run_end` (не у всех). У annihil суффикс `_angel`. Длительности: draw 0.96 до 1.12 с, melee 1.12 с, inspect от 6 до 13 с. + +Формат клипа: `{"loop":true,"animation_length":..,"override_previous_animation":..,"bones":{:{"rotation":{"0.0":[x,y,z],...},"position":{...},"scale":{...}}},"sound_effects":{"t":{"effect":"melee..."}}}`. Ключи времени: строка секунд (шаг около 0.04 с). + +### 3.5 Анимация: осмотр, доставание, удар, бег + +Класс состояния: `evovis/internal/Inspect.java` (`defpackage/ahl.java`). Два экземпляра: `Inspect.getInstance()` (главная рука) и `Inspect.offHand()`. Состояние: + +- Три трека (`BaseTrack`, `evovis/internal/BaseTrack.java`): `baseTrack` (вечная `static_idle`, режим `Mode6.LOOP`), `mainTrack` (одноразовые: draw, inspect, melee), `movementTrack` (`run_start` > `run` > `run_end`). Режимы `Mode6`: `LOOP`, `ONCE_STOP`, `ONCE_HOLD`. +- Конечный автомат: `MainStateMode.START` > при привязке модели проигрывает `draw` или (50% если есть) `draw_1` > `IDLE`. `MovementStateMode` для бега. +- Осмотр: `onInspect()` (вызывается по клавише из `MeleeSkinsModule.onKeyPressed`) работает только в `IDLE`; с 50% шансом играет `inspect_1` (если клип есть), иначе `inspect`. Переход 0.2 с. +- Атаки: `onAttack(boolean primary)`. ЛКМ чередует `melee_1` и `melee_2` (`PRIMARY_ATTACK_VARIANTS = 2`), ПКМ играет `melee_3`. Переходы 0.2 с (`TRANSITION_ACTION`). Хуки: `evovis/mixin/MeleeInputMixin.java` (`Minecraft.startAttack` HEAD, `Minecraft.startUseItem` RETURN, если не используется оффхенд). +- Бег: только при `player.isSprinting()`; `run_start` (0.2 с) > `run` (цикл по пройденной дистанции, `RUN_CYCLE_DISTANCE = 2.0` блока) > `run_end` (0.3 с). Не у всех ножей есть run-клипы (нет у bayonet/karambit/m9 и др.). +- Смена предмета/слота: `heldChanged` перепривязывает модель (`bind`), случайный вариант (`Variant`) выбирается из `variants`, скрытые кости помечаются `Model2.markHidden`. +- Композиция: `compose()` смешивает статичную позу (static fade) и аддитивную (additive fade) в `float[] composedPose` (на кость 9 float: rot xyz, pos xyz, scale xyz). Время считается по `System.nanoTime`, максимальный шаг 0.25 с. +- Интерполяция клипа: `evovis/internal/Internal68.java` (`CHANNEL_STRIDE=3`, `BONE_STRIDE=9`, Catmull-Rom сплайн; константы `CATMULL_HALF/TWO/FIVE`) и `Tracks.java` (`sample(time, float[] out)` по каналам rotation/position/scale). +- Звуки: `evovis/internal/Melee5.java` (`aia`): события `melee.*` из sounds.json, `play(name)` через `SimpleSoundInstance` в позиции игрока (SoundSource.PLAYERS, volume/pitch 1.0). `BaseTrack` ведёт курсор `sound_effects` (capacity 4) и останавливает звук при смене клипа. + +### 3.6 Рендер + +Хуки (все в `evovis/mixin/`): + +- `MeleeItemInHandMixin` (на `ItemInHandRenderer`): `submitHandsWithItems` HEAD вызывает `Thirdperson.beginHands()`, RETURN вызывает `flushHandItem(...)` (отложенно рисует предмет в левой руке модели ножа); `submitArmWithItem` HEAD cancellable: если `MeleeSkinsModule.rendersFirstPerson(item)` то `Thirdperson.renderFirstPerson(player, hand, matrices, queue, light, retireProgress)` и отмена ванильного рендера; пустую главную руку скрывает `hidesEmptyMainHand`; не-меч в другой руке откладывается `deferHandItem`. +- `MeleePlayerItemInHandLayerMixin` (на `PlayerItemInHandLayer`): `submitArmWithItem` HEAD cancellable для третьего лица: `renderer.getModel().translateToHand(state, arm, matrices)`, `rotation XP -90`, `YP 180`, затем `Thirdperson.renderThirdPerson(...)` (только для своего игрока, `player.getId() == state.id`). +- `MeleeInputMixin` (на `Minecraft`): атака, см. выше. + +Класс рендера: `evovis/internal/Thirdperson.java` (`defpackage/...`, на деле рендер первого и третьего лица). Основные приёмы: + +- `buildWorld(PrimaryBones, Model2, pose[], hidden[])` считает мировые матрицы костей из позы клипа (`PrimaryBones`: `world`, `combined`, `normals`, `visible`). +- `applyCamera(model, inspect, poseStack)` применяет кость `camera` (покачивание камеры при осмотре и атаке). `applyViewAnchor` якорит модель по кости `idle_view`. +- Руки: `submitArm(...)` рендерит руки игрока (скин через аватар-рендер) в точках `righthand_pos` и `lefthand_pos` модели, `hideHands` скрывает нерелевантные. Левая рука зеркалится при левшах (`handMirrored`, `scale(-1,1,1)`). +- Геометрия ножа: `submitGeometry(...)` отправляет меш каждой кости через `submitCustomGeometry` с `RenderType` entityTranslucent и текстурой скина. Для `holyBones` используется `Melee.holy(texture)` (`evovis/internal/Melee.java`, `ahr.java`): `RenderPipeline "pipeline/melee_holy"` из `evolution:core/melee_holy`, формат вершин `DefaultVertexFormat.ENTITY`, translucent blend, без cull. +- Запечённая модель: `evovis/internal/Model2.java`: массивы `boneNames/boneParents/bonePivots/boneRestRotation`, `boneMeshes[i]` (float[]: на вершину 8 float: pos(3), uv(2), normal(3); `VERTEX_STRIDE = 8`, 4 вершины на грань), `clips: Map`, индексы `rightHandBone/leftHandBone/cameraBone/viewAnchorBone`. Бейк `Model2.bake(Definition, key)` из того же `Rotation2`-парсера, что и крылья. +- Загрузка и кэш: `evovis/internal/Melee4.java` (`ahx`): фоновый поток "evo-melee-loader", `ConcurrentHashMap` кэш, `LOADING` set, вытеснение (`evict`). Модель читается из `/assets/evolution/cosmetic/melee/.geo.json` и `.anim.json` (`Melee2.modelResource()/animationResource()`). +- Третье лицо и инвентарь: кость `thirdperson_hand`, в `index.json` блок `transforms.thirdperson_righthand/lefthand` задаёт rotation/translation на скин, `ground/fixed/gui/head` для других display-контекстов (в evo на них отдельные поля не используются в коде рендера кроме third-person, но в данных есть). + +### 3.7 Что нужно у нас для отдельного модуля Knife + +Новый модуль: `features/module/modules/visuals/items/knife/Knife.java` (`@ModuleInfo(id="knife", displayName="Knife", category=ModuleCategory.VISUALS` или `ACCESSORIES`). Классы: + +| Роль | Предлагаемое имя | Прототип в evo | +|---|---|---| +| Модуль и настройки | `Knife` (скин, firstPerson, thirdPerson, inspectKey, scale, offset, звук) | `MeleeSkinsModule` | +| Каталог и дескриптор скина | `KnifeCatalog`, `KnifeSkin` (record: key, geoKey, name, variants, holyBones, thirdPerson transforms, texture, icon) | `Melee3`, `Melee2`, `Variant`, `ThirdPersonLeftHand` | +| Парсер geo и бейк | `BedrockGeoParser` (общий с крыльями), `KnifeModel` | `Rotation2`, `Definition`, `Parent`, `Model2` | +| Клипы и сэмплинг | `KnifeClip`, `KnifeClipTrack` (LOOP/ONCE_STOP/ONCE_HOLD), `KnifeAnimationState` | `Internal68`, `Tracks`, `BaseTrack`, `Mode6`, `Inspect` | +| Рендер первого лица | `KnifeFirstPersonRenderer` | `Thirdperson.renderFirstPerson`, `buildWorld`, `applyCamera`, `applyViewAnchor`, `submitArm`, `submitGeometry` | +| Рендер третьего лица | `KnifeThirdPersonRenderer` | `Thirdperson.renderThirdPerson` | +| Шейдер holy | `KnifeRenderTypes` + шейдер `assets/lovisual/shaders/knife_holy.{vsh,fsh}` (писать по образцу `melee_holy`, регистрация pipeline в `LoVisualRenderPipelines`) | `Melee` + `melee_holy.vsh/fsh` | +| Звуки | `KnifeSounds` + `sounds.json` в `assets/lovisual/` | `Melee5` + `evolution/sounds.json` | +| Загрузка | `KnifeModelCache` (асинхронная, кэш, вытеснение) | `Melee4` | +| Миксины | `KnifeInputMixin` (на `Minecraft`), `KnifeItemInHandMixin` (на `ItemInHandRenderer`), `KnifePlayerItemInHandLayerMixin` (на `PlayerItemInHandLayer`) | `MeleeInputMixin`, `MeleeItemInHandMixin`, `MeleePlayerItemInHandLayerMixin` | + +Ресурсы к копированию (источник, для отладки; см. предупреждение о лицензии): весь каталог `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (`index.json`, 21 `.geo.json`, 21 `.anim.json`), все png из `.../textures/cosmetic/melee/` и `.../textures/cosmetic/melee/slot/` (кроме `.ppm`), каталог `.../sounds/melee/` + выбранные ключи из `.../sounds.json`, шейдеры `.../shaders/core/melee_holy.{vsh,fsh}`. Целевые пути у нас: `assets/lovisual/knife/*.json`, `assets/lovisual/textures/knife/*.png`, `assets/lovisual/sounds/knife/*.ogg`. + +Что у нас уже есть и можно переиспользовать: `ItemInHandRendererMixin` (`mixins/render/item/ItemInHandRendererMixin.java`, уже перехватывает `submitArmWithItem` и `submitHandsWithItems`-путь), `HoldMyItems`/`ViewModel` (смещения руки), `EquipmentLayerRendererMixin`, текстуры через `Identifier`, `LoVisualRenderPipelines`. Чего нет и надо сделать с нуля: парсер Bedrock geo, система клипов, пайплайн и шейдер holy, миксин на `PlayerItemInHandLayer`, миксин ввода (атака/ПКМ), каталог скинов, UI выбора (иконки слотов). + +Порядок работы: 1) общий `BedrockGeoParser` + бейк (он же нужен для крыльев), 2) клипы + `KnifeAnimationState` (draw, static_idle, inspect, melee_1..3 без бега), 3) рендер первого лица без рук (только нож), 4) руки игрока в `righthand_pos`/`lefthand_pos`, 5) третье лицо, 6) звуки, 7) бег, варианты annihil, holy-шейдер, 8) UI и сохранение выбора. + +--- + +## 4. HoldMyItems и Lua ("нода на Lua") + +### 4.1 Как это устроено в evo + +evo бандлит оригинальный мод HoldMyItems целиком (миксин-конфиг `holdmyitems.mixins.json`, лист `"package":"evovis.mixin"`) и обёртку-модуль `HmiModule` (`evovis/module/render/HmiModule.java`: только флаг `active()`). Движок скриптов: LuaJ 3.0.1 (`/storage/project/jvm/LoVisual/ref/evo/mod/jar/META-INF/jars/luaj-jse-3.0.1.jar`, исходники `evovis`-дерева `org/luaj/vm2`). + +Скрипты (источник): `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/minecraft/holdmyitems/` + +| Файл | Размер | Назначение | +|---|---|---| +| `hand_pose.lua` | 66 КБ | поза рук "сцены" (экипировка, использование: bow, crossbow, trident, spear, eat/drink, brush, block, подмена свинга для мечей и т.д.) | +| `hand_relative_pose.lua` | 1.6 КБ | поза руки относительно сцены (пустая рука, смена предмета, бросаемое) | +| `item_pose.lua` | 45 КБ | поза самого предмета в руке | +| `item_model.lua` | 20 КБ | анимация частей (квадов) модели предмета: вёдра, лодки, пакеты | +| `hand_pose.lua.rpo`, `item_pose.lua.rpo` | 157 Б | опции Respackopts (`hmi.xOffset/yOffset/zOffset/inspectKeybind`) | + +Необязательные "аддоны" подхватываются как `holdmyitems/hand_addon.lua`, `hand_relative_addon.lua`, `item_addon.lua`, `item_model_addon.lua` (со всех ресурс-паков, `getAllResources`). + +Ресурсы вокруг скриптов (источник `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/minecraft/`): `items/*.json` (35, лодки, вёдра, end_crystal и др.), `models/item/*.json` (48 3D/2D моделей, `eatinganimation/` 17 моделей), `textures/item/` (58), `textures/particle/` (36, пламя и glow для Lua-частиц), `optifine/`. + +Классы evo (читаемые имена; оригинал в скобках): + +- Загрузчик: `evovis/internal/Holdmyitems.java` (`SimpleSynchronousResourceReloadListener`, id `holdmyitems:lua_animation_loader`). Грузит 4 основных + 4 вида аддонов при перезагрузке ресурсов. Препроцессор `preprocessScript`: строки `global.x = v;` превращаются в `local x = registry:getOrDefault('x', v)` и в конце скрипта добавляется `registry:put('x', x)` (персистентные между кадрами переменные); плейсхолдеры `${name}` заменяются на `0`, если нет мода respackopts. +- Песочница и глобалы: `evovis/internal/Internal82.java` (`LuaScriptManager`): `Globals` с библиотеками Base, Package, Bit32, Table, String, Coroutine, JseMath, `LoadState.install`, `LuaC.install`. +- Кэш скрипта руки/предмета: `evovis/internal/HandScriptCache.java` (аналог `LuaScriptCache`): регистрирует в Lua глобалы `M, I, Texture, Items, Tags, P, Easings, KeyBindManager, S, C, particleManager, swingSpeed, registry, renderAsBlock, translateItem, itemSwingSpeed, animator, useDuration, usingItem, debugger, applyBlockRotation, context` и вызывает `chunk.invoke()` на каждый кадр. При ошибке Lua: тост "HMI Lua Runtime error!" и `canRun=false` до перезагрузки. +- Кэш скрипта модели: `evovis/internal/ItemModelCache.java` (глобал `data` вместо `context`), `executeModel`. +- Контекст кадра: `Context` (поля `mainHand, hand, bl (правая ли рука), swingProgress, mainHandSwingProgress, offHandSwingProgress, equipProgress, matrices (PoseStack), item, player, deltaTime, mainHandSwitchEvent, offHandSwitchEvent, swingMHand, swingOHand, interact, blockBreaking, particles`). +- Опасная часть: `evovis/mixin/JavaMethodMixin.java` патчит `org.luaj.vm2.lib.jse.JavaMethod.invokeMethod` и возвращает NIL для любого Java-метода без аннотации `@Safe` (кроме `getOrDefault`/`put`): так защищают от того, что Lua дёргает произвольные Java-методы. +- Обёртки API (Lua-видимые): `MInstance` (`M:`: moveX/Y/Z, rotateX/Y/Z (с pivot), scale, translate, shear, push/pop, sin/cos/abs/round/floor/ceil/pow/clamp/lerp), `IInstance` (`I:`: isOf, isIn, isEmpty, isBlock, isThrowable, isLantern, isChargedCrossbow, isEnchanted, getUseAction, getName, getAttackDamage, getSpearData, setSwingSpeed, setRenderAsBlock, setTranslate, shouldRenderAsBlock, shouldTranslateItem, isCustomTranslate, setMainStack/setOffStack, getComponents, copyAppearanceComponents, getDefaultStack), `PInstance` (`P:`: getSpeed, getXSpeed/YSpeed/ZSpeed, getYaw/Pitch, getX/Y/Z, getAge, getHealth, isOnGround, isSneaking, isSwimming, isCrawling, isClimbing, isUsingItem, isUsingRiptide, isUsingSpyglass, isTouchingWater, isSubmergedInWater, hasVehicle, isItemCoolingDown, getActiveHand, getMainItem, getOffhandItem, getSwingCount, getBlockBelow/Above/Standing), `EasingsInstance` (30 easing функций: easeInOutBack, easeOutElastic, easeOutBounce, cubicEase и др.), `JsItemsInstance` (`Items:get("minecraft:...")`), `JsTagsInstance` (`Tags:getVanillaTag/getFabricTag`), `KeyBindManagerInstance` (`isKeyPressed`), `SInstance` (`S:playSound`), `CInstance` (`C:setCamRot/setCamPos`, камера), `TextureInstance`, `ParticleManagerInstance` (`particleManager:addParticle(...)` с Lua-колбэком на тик; сама система частиц `Particle`, `ParticleManager`, рендер-типы в мире и в руке). +- Анимация частей модели предмета: `ModelPartAnimator` (`animator:rotateX/Y/Z(from, to, deg, pivotX, pivotY, pivotZ)`, `moveX/Y/Z`, `scale` по диапазону индексов частей `from..to`, применяется при рендере квадов через `ItemRendererMixin`/`BlockRenderManagerMixin`). + +Чистый, не обфусцированный референс тех же классов: `/storage/project/jvm/LoVisual/ref/clients/mercury-src/com/holdmylua/source/`: +`lua_runtime/{LuaScriptManager,LuaScriptCache,ModelScriptCache,ScriptHolder}.java`, `lua_runtime/resource_controller/LuaAnimationResourceLoader.java`, `global/{LuaContext,GlobalsStorage,DispatcherStorage}.java`, `global/item_model/*`, `scripting/script_wrappers/{M,P,IInstance,Easings,C,S,JSItems,JSTags}.java`, `scripting/custom_api/*`, `model/{ModelPartAnimator,PoseX/Y/Z,RotationX/Y/Z,ScalePose}.java`, `patricles/*` (Particle 450 строк, ParticleManager), `annotation/Safe.java`, `access/*` (аксессоры), `SwordAttacks.java`, `data_structures/SpearData.java`, `compat/IrisCompat.java`. +Миксины: `/storage/project/jvm/LoVisual/ref/clients/mercury-src/fun/mercury/mixin/hmi/*` (BlockRenderManagerMixin, HandRenderTypeMixin, HmiCameraMixin, HmiGameRendererMixin (deltaTime по GLFW, не более 0.05), HmiLivingEntityMixin (счётчики свинга), HmiMinecraftClientMixin (doAttack, doItemUse), ItemRendererMixin, ItemRenderStateMixin, ItemStackMixin (поля transform, swingSpeed, renderAsBlock), JavaMethodMixin, UseDurationMixin, UsingItemMixin) и главный `HeldItemRendererMixin.java` (905 строк; точки вызова скриптов: `hmi$itemPose` > `item_pose`, `hmi$mainHandPose` > `hand_relative_pose`, `hmi$scenePoseMain` > `hand_pose`; `hmi$applyArmMatrices` копирует ванильное построение руки; `wrapApplyEquipOffset`/`wrapSwingArm` отключают ванильную экипировку и свинг; `hmi$renderOverhaul` переопределяет `renderItem`). + +Другие Lua-системы в ref (для идей "нод"/скриптов модулей, не руки): + +- `/storage/project/jvm/LoVisual/ref/clients/sacura-1.21.4/src/main/java/su/sacura/util/impl/lua/`: `LuaManager` (папка `/sacura/lua`, вызывает глобальные `onEnable/onDisable/onUpdate/onRender` у каждого скрипта-модуля), `LuaScript` (скрипт как `Module`), API `ClientApi, GlobalApi, ModulesApi, PlayerApi, Render3DApi, RenderApi, SettingsApi, Vec2`, `render/LuaFontManager`. Зависимость: `org.luaj:luaj-jse:3.0.1` (в `build.gradle` как `include`). +- `/storage/project/jvm/LoVisual/ref/clients/newcode-1.21/` (`FiguraAvatarInstaller`, `figura_avatars/*/script.lua`): Figura-аватары на Lua (`test_luaj.gradle`). +- `/storage/project/jvm/LoVisual/ref/clients/mercury-src/` (HoldMyLua, выше). +- `/storage/project/jvm/LoVisual/ref/README.md` упоминает `hmi` (HoldMyItems event API) и `mercury-src` (HoldMyLua), но каталога `ref/clients/hmi` на диске сейчас нет; в `mod/TODO.md` строки 546-560 описан разбор HMI.zip в `/tmp/lv_refs/hmi/` (Delta HMI, обёртка над сторонним `holdmyitems-5.1.1+26.2.jar`). + +### 4.2 Что есть у нас + +Только чистая Java-динамика, без Lua и без скриптов: + +- `features/module/modules/visuals/items/held/HoldMyItems.java` (`id="holdmyitems"`, displayName "Item Holder"): настройки `sway_intensity`, `ease_strength`, `offhand`, `walk_bob`/`bob_strength`, `use_drift`/`drift_strength`, `join_appear`, `grip_enabled` + 6 слайдеров grip (`gripExtend/Slide/Out/Forward/Tilt/Turn/Roll`). Метод `onFrame` вызывает `HandAnimState.beginFrame`. Метод `shapeEquipProgress`. +- `util/animation/hand/HandDynamics.java` (применяет позу в `submitArmWithItem` HEAD через `pushPose`), `HandPoseMath` (sway, bob, drift, joinDrop, grip), `HandAnimState`, `SwayState`, `BobMath`, `HeldAlongFist`. +- `util/animation/EquipProgressComposer.java` + `EaseCurves` (кривая equip). +- `mixins/render/item/ItemInHandRendererMixin.java`: инъекции `submitArmWithItem` HEAD/RETURN (динамика руки), HEAD (ViewModel offsets), `@ModifyVariable` equipProgress (ordinal 3), `renderItem` (мини-предметы), `swingArm` (кастомные свинги ViewModel `Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust` из `visuals/camera/swing/*`). +- Родственные: `visuals/camera/ViewModel.java`, `visuals/items/held/AlwaysVisibleHand.java`, `mixins/render/item/ItemInHandAlwaysVisibleMixin.java`. + +Решение по Lua в `mod/TODO.md` (строки 546-558): полный HMI не портировали из-за правила "без стороннего jar в сборке" и KISS; взята только идея grip. Скриптового движка для рук нет. В проекте есть только Javet (V8) для UI/HUD-скриптов: `render/engine/renderer/ui/runtime/script/engine/{UiScriptEngine,JavetUiScriptEngine,UiScriptEngineProvider}.java` (подключение в `build.gradle`: `bundledLibrary "com.caoccao.javet:javet:5.0.9"`, строка 186). Lua/LuaJ в проекте отсутствует (в зависимостях нет `luaj`). + +### 4.3 Чего не хватает до полного функционала HMI (список) + +Движок и инфраструктура: + +1. Зависимость LuaJ: `bundledLibrary 'org.luaj:luaj-jse:3.0.1'` в `mod/build.gradle` рядом со строкой Javet. Версия та же, что у evo, mercury и sacura. Лицензия LuaJ (MIT) допускает бандл, но это "сторонняя библиотека в сборке", как Javet, а не сторонний мод. +2. Пакет `features/module/modules/visuals/items/held/lua/`: `LuaHandRuntime` (общие `Globals`, как `Internal82`), `HandScript` (аналог `HandScriptCache`: 3 основных скрипта руки/предмета + аддоны), `ItemModelScript` (аналог `ItemModelCache`), `HandScriptContext` (поля из `Context`), `ScriptHolder`, `LuaScriptPreprocessor` (регэкспы `global.x = v;` > `registry`, `${name}` > `0`), `HmiResourceReloadListener` (загрузка `holdmyitems/*.lua` из ресурс-паков, плюс из `config/lovisual/holdmyitems/` для пользовательских скриптов). +3. Песочница. Evo и mercury используют миксин на `JavaMethod` + `@Safe`. Лучше без миксина на сторонний класс: не отдавать Lua `CoerceJavaToLua.coerce(javaObject)` (даёт доступ ко всем public-методам, включая `getClass()`), а собирать `LuaTable` с `LibFunction`-обёртками только нужных методов (M, I, P, Easings, Items, Tags, S, C, KeyBindManager, particleManager, registry). `Globals` без `JseBaseLib.loadfile/dofile` и без `luajava`, `io`, `os`. Это закрывает вопрос безопасности скриптов из чужих ресурс-паков. +4. Реестр персистентных переменных `registry` (HashMap) со сбросом при перезагрузке ресурсов. +5. Дельта времени кадра: по GLFW (`HmiGameRendererMixin`: `min(0.05, dt)`, 0 при паузе). У нас есть `render/helpers/util/TickDelta`. +6. Тосты/лог ошибок Lua (`canRun=false` до reload). + +Хуки рендера (нашего `ItemInHandRendererMixin` для этого мало): + +7. Подмена всей цепочки руки: обернуть/перенаправить вызов построения руки и предмета (аналог `hmi$renderOverhaul`, `wrapApplyEquipOffset`, `wrapSwingArm`), чтобы Lua управлял `equipProgress` и свингом вместо ванили. Сейчас у нас только `ModifyVariable` equipProgress, `swingArm` cancel для ViewModel и pushPose/popPose. +8. Раздельные фазы скриптов: `hand_pose` (сцена), `hand_relative_pose` (рука относительно), `item_pose` (предмет), плюс вызов `item_model` при рендере модели. +9. Счётчики свинга и события (`mainHandSwingProgress/offHandSwingProgress`, `swingMHand/swingOHand`, `interact`, `blockBreaking`, `mainHandSwitchEvent/offHandSwitchEvent`): миксины `HmiLivingEntityMixin`, `HmiMinecraftClientMixin` (doAttack, doItemUse), `getSwingCount`. +10. Рендер обеих рук всегда (`HandRenderTypeMixin`: `renderMainHand/renderOffHand = true`). +11. Подмена параметров ItemStack на лету: `ItemStackMixin` (поля transform, swingSpeed, renderAsBlock) и `setMainStack/setOffStack`, `copyAppearanceComponents`. +12. Подмена свойств item-моделей `use_duration` и `using_item` (`UseDurationMixin`, `UsingItemMixin`) для анимаций еды/лука/арбалета. +13. Аниматор частей модели (`ModelPartAnimator` + хуки на рендер квадов `ItemRendererMixin`, `BlockRenderManagerMixin`, `ItemRenderStateMixin` сброс диспетчера) для `item_model.lua` (вёдра с рыбой, лодки, пакеты). +14. Камера из Lua (`C:setCamRot/setCamPos`, `HmiCameraMixin`) для crawl/climb/riptide. +15. Lua-частицы: `Particle`, `ParticleManager`, рендер-типы (мир/рука), `particleManager:addParticle` с Lua-колбэком (пламя, glow). +16. Привязка клавиши осмотра (`KeyBindManager`, `hmi.inspectKeybind`, `S:playSound`). У нас есть настройки кнопки в ClickGUI (`OpenKeySetting`-аналог), надо пробросить в Lua. + +Контент: + +17. Сами скрипты: `hand_pose.lua`, `hand_relative_pose.lua`, `item_pose.lua`, `item_model.lua` (источник: каталог из 4.1). Для релиза свои или с разрешением автора HMI/evo. Скрипты завязаны на ресурсы `assets/minecraft/items/*.json`, `models/item/*` (3D-вёдра, лодки, eatinganimation), `textures/particle/*`. +18. Настройки в ClickGUI: выбор набора скриптов, переключатели per-feature (inspect, eating, bow, crossbow, trident, spear, sword attacks), горячая перезагрузка скриптов (`.reload`-команда или кнопка). +19. Тесты: препроцессор `global.x`, песочница (скрипт не вызывает `os/io`, не достаёт `getClass`), математика `M:` и `Easings:` (по образцу `HandPoseMath`-тестов). + +Что у нас лучше evo: готовые Java-режимы (sway/bob/drift/grip), настраиваемые слайдеры в ClickGUI, ViewModel-свинги, нет зависимости от Lua-интерпретатора и риска чужих скриптов. Рекомендация: сначала добавить пункты 1-8 как режим "Scripted" внутри существующего `HoldMyItems` (переключатель `Dynamics (Java)` / `Scripts (Lua)`), собственные короткие примеры скриптов, и лишь затем наращивать 9-16. + +### 4.4 Минимальный план "нода на Lua" (если нужна именно скриптуемая нода) + +Идея аналогична sacura `LuaManager`/`LuaScript`: Lua-файл = модуль/узел, который объявляет `onEnable`, `onDisable`, `onUpdate`, `onRender`. Минимум: + +1. `LuaScriptManager` + `LuaScript` (по образцу `sacura/util/impl/lua`), папка `/lovisual/lua`, регистрация скрипта как `Module` в нашем реестре модулей (`features/module/lifecycle/Modules`). +2. Сначала только безопасные API: `Player`, `Render3D` (линии, квады через `Renderer3D`), `Settings` (создание num/bool/mode значений как у `Module.num/bool/modeSetting`), `Client` (chat print). +3. Для hold-items узла: отдельный тип скрипта `held`, вызываемый из `ItemInHandRendererMixin` с `HandScriptContext`, `matrices`, глобалами `M, I, P, Easings` из 4.1. + +Альтернатива Lua: у нас уже есть Javet/V8 (`UiScriptEngine`). Если совместимость с готовыми HMI-паками не нужна, то скрипты рук можно писать на JS и не тянуть второй интерпретатор. Но экосистема HMI (готовые `hand_pose.lua` и т.д.) только на Lua, поэтому для "полного функционала HMI" нужен именно LuaJ. + +--- + +## 5. Итоговый приоритет работ + +1. Общий `BedrockGeoParser` + бейк + сэмплер анимации (нужен и крыльям, и ножам). Затем режим "модель" в Wings (раздел 2.4): ближайший заметный результат при умеренной сложности (M). +2. Модуль Knife (раздел 3.7): самая эффектная фича, сложность L, строить этапами. +3. Быстрые модули: TNTTimer, AutoSprint, TotemCounter, PlaceAnimation, EnchantGlint, ArmorColor, GodRays, ProjectileTrails. +4. DeathEffects, Blizzard, ElytraTrails. +5. LuaJ-движок и скриптовый режим HoldMyItems (раздел 4.3, пункты 1-8), потом остальное. +6. Лицензионный вопрос по ассетам evo решить до начала копирования любых файлов. diff --git a/mod/gradle.properties b/mod/gradle.properties index c2e5b57..4666d79 100644 --- a/mod/gradle.properties +++ b/mod/gradle.properties @@ -18,3 +18,5 @@ xaero_minimap_version=fabric-26.2-26.1.3 xaero_worldmap_version=fabric-26.2-1.41.1 viafabricplus_version=4.6.0 entityculling_version=1.10.5 +voicechat_api_version=2.6.24 +mixinextras_version=0.5.5 diff --git a/mod/src/main/java/dev/loki/lovisual/compat/voicechat/LoVisualVoicechatPlugin.java b/mod/src/main/java/dev/loki/lovisual/compat/voicechat/LoVisualVoicechatPlugin.java new file mode 100644 index 0000000..28c2f1d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/compat/voicechat/LoVisualVoicechatPlugin.java @@ -0,0 +1,61 @@ +package dev.loki.lovisual.compat.voicechat; + +import de.maxhenkel.voicechat.api.VoicechatApi; +import de.maxhenkel.voicechat.api.VoicechatClientApi; +import de.maxhenkel.voicechat.api.VoicechatPlugin; +import de.maxhenkel.voicechat.api.events.ClientReceiveSoundEvent; +import de.maxhenkel.voicechat.api.events.ClientSoundEvent; +import de.maxhenkel.voicechat.api.events.ClientVoicechatConnectionEvent; +import de.maxhenkel.voicechat.api.events.EventRegistration; +import de.maxhenkel.voicechat.api.events.MicrophoneMuteEvent; + +/** + * Simple Voice Chat plugin entrypoint ({@code "voicechat"} in fabric.mod.json). Only loaded + * by the voice chat mod itself, so nothing here is ever resolved when it is not installed. + */ +public final class LoVisualVoicechatPlugin implements VoicechatPlugin { + + @Override + public String getPluginId() { + return "lovisual"; + } + + @Override + public void initialize(VoicechatApi api) { + if (api instanceof VoicechatClientApi client) { + VoiceChatBridge.attach(new ClientProbe(client)); + } + } + + @Override + public void registerEvents(EventRegistration registration) { + VoiceState state = VoiceChatBridge.rawState(); + registration.registerEvent(ClientReceiveSoundEvent.EntitySound.class, + e -> state.markSpeaking(e.getEntityId(), e.isWhispering())); + registration.registerEvent(ClientSoundEvent.class, e -> state.markSelfSpeaking(e.isWhispering())); + registration.registerEvent(MicrophoneMuteEvent.class, e -> state.setMuted(e.isDisabled())); + registration.registerEvent(ClientVoicechatConnectionEvent.class, e -> state.setConnected(e.isConnected())); + } + + private record ClientProbe(VoicechatClientApi api) implements VoiceChatBridge.SelfProbe { + @Override + public boolean muted() { + return api.isMuted(); + } + + @Override + public boolean disabled() { + return api.isDisabled(); + } + + @Override + public boolean disconnected() { + return api.isDisconnected(); + } + + @Override + public boolean pushToTalk() { + return api.isPushToTalkKeyPressed(); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceChatBridge.java b/mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceChatBridge.java new file mode 100644 index 0000000..a881155 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceChatBridge.java @@ -0,0 +1,52 @@ +package dev.loki.lovisual.compat.voicechat; + +import net.fabricmc.loader.api.FabricLoader; + +/** + * Safe entry point to Simple Voice Chat data. Never touches the voice chat API itself, so + * any class may use it whether or not the mod is installed; the plugin feeds it when present. + */ +public final class VoiceChatBridge { + public static final String MOD_ID = "voicechat"; + + private static final VoiceState STATE = new VoiceState(System::currentTimeMillis); + private static volatile SelfProbe probe; + + private VoiceChatBridge() { + } + + /** Live reads of the local microphone, implemented by the plugin on top of the voice chat API. */ + public interface SelfProbe { + boolean muted(); + + boolean disabled(); + + boolean disconnected(); + + boolean pushToTalk(); + } + + public static boolean isInstalled() { + return FabricLoader.getInstance().isModLoaded(MOD_ID); + } + + /** Current state with the local microphone flags refreshed; cheap enough to call every frame. */ + public static VoiceState state() { + SelfProbe p = probe; + if (p != null) { + STATE.setMuted(p.muted()); + STATE.setDisabled(p.disabled()); + STATE.setPushToTalk(p.pushToTalk()); + STATE.setConnected(!p.disconnected()); + } + return STATE; + } + + static VoiceState rawState() { + return STATE; + } + + static void attach(SelfProbe selfProbe) { + probe = selfProbe; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceState.java b/mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceState.java new file mode 100644 index 0000000..6884bea --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/compat/voicechat/VoiceState.java @@ -0,0 +1,120 @@ +package dev.loki.lovisual.compat.voicechat; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + +/** + * Thread-safe snapshot of who is talking in Simple Voice Chat. Written from the voice + * threads by the plugin, read from the render thread by the module and the HUD. Holds no + * Simple Voice Chat or Minecraft types, so it stays testable and safe to load without the mod. + */ +public final class VoiceState { + + /** State of the local microphone, ordered by display priority. */ + public enum MicStatus { DISCONNECTED, DISABLED, MUTED, WHISPERING, TALKING, PUSH_TO_TALK, IDLE } + + /** One remote speaker; {@code lastMs} is when audio from them was last received. */ + public record Speaker(UUID id, boolean whisper, long lastMs) { } + + private record Voice(boolean whisper, long lastMs) { } + + private static final Comparator NEWEST_FIRST = Comparator.comparingLong(Speaker::lastMs).reversed(); + + private final LongSupplier clock; + private final Map speakers = new ConcurrentHashMap<>(); + private volatile Voice self = new Voice(false, Long.MIN_VALUE); + private volatile boolean connected; + private volatile boolean muted; + private volatile boolean disabled; + private volatile boolean pushToTalk; + + public VoiceState(LongSupplier clock) { + this.clock = clock; + } + + public void markSpeaking(UUID id, boolean whisper) { + if (id != null) speakers.put(id, new Voice(whisper, clock.getAsLong())); + } + + public void markSelfSpeaking(boolean whisper) { + self = new Voice(whisper, clock.getAsLong()); + } + + public void setConnected(boolean value) { + connected = value; + if (!value) speakers.clear(); + } + + public void setMuted(boolean value) { muted = value; } + + public void setDisabled(boolean value) { disabled = value; } + + public void setPushToTalk(boolean value) { pushToTalk = value; } + + public boolean isConnected() { return connected; } + + public void clear() { + speakers.clear(); + self = new Voice(false, Long.MIN_VALUE); + } + + public boolean isSpeaking(UUID id, long windowMs) { + Voice v = speakers.get(id); + return v != null && age(v) <= windowMs; + } + + public boolean isWhispering(UUID id, long windowMs) { + Voice v = speakers.get(id); + return v != null && v.whisper() && age(v) <= windowMs; + } + + /** Fade factor: 1 while audio is arriving, falling linearly to 0 over {@code fadeMs} after it stops. */ + public float level(UUID id, long fadeMs) { + Voice v = speakers.get(id); + return v == null ? 0f : fade(age(v), fadeMs); + } + + public float selfLevel(long fadeMs) { + return fade(age(self), fadeMs); + } + + public boolean isSelfWhispering(long windowMs) { + Voice v = self; + return v.whisper() && age(v) <= windowMs; + } + + /** Remote speakers heard within {@code windowMs}, newest first; stale entries are dropped. */ + public List speakers(long windowMs, int max) { + List out = new ArrayList<>(); + speakers.entrySet().removeIf(e -> age(e.getValue()) > Math.max(windowMs * 4, 10_000L)); + for (Map.Entry e : speakers.entrySet()) { + if (age(e.getValue()) <= windowMs) { + out.add(new Speaker(e.getKey(), e.getValue().whisper(), e.getValue().lastMs())); + } + } + out.sort(NEWEST_FIRST); + return out.size() > max ? new ArrayList<>(out.subList(0, Math.max(0, max))) : out; + } + + public MicStatus micStatus(long windowMs) { + if (!connected) return MicStatus.DISCONNECTED; + if (disabled) return MicStatus.DISABLED; + if (muted) return MicStatus.MUTED; + if (age(self) <= windowMs) return self.whisper() ? MicStatus.WHISPERING : MicStatus.TALKING; + return pushToTalk ? MicStatus.PUSH_TO_TALK : MicStatus.IDLE; + } + + private long age(Voice v) { + return v.lastMs() == Long.MIN_VALUE ? Long.MAX_VALUE : clock.getAsLong() - v.lastMs(); + } + + private static float fade(long ageMs, long fadeMs) { + if (ageMs == Long.MAX_VALUE || ageMs > fadeMs) return 0f; + return fadeMs <= 0 ? 1f : 1f - (float) ageMs / fadeMs; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/config/i18n/MainConfig.java b/mod/src/main/java/dev/loki/lovisual/config/i18n/MainConfig.java index 26411cf..ef2fae7 100644 --- a/mod/src/main/java/dev/loki/lovisual/config/i18n/MainConfig.java +++ b/mod/src/main/java/dev/loki/lovisual/config/i18n/MainConfig.java @@ -35,6 +35,10 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti private final ModeValue menuBg = new ModeValue("menuBg", "waves", "off", "waves", "aurora"); + //@CFGComment("Main menu layout: orbs / hero") + private final ModeValue menuLayout = + new ModeValue("menuLayout", "orbs", "orbs", "hero"); + //@CFGComment("Use ClickGUI theme colors for menu background shader") private final BooleanValue menuBgUseTheme = new BooleanValue("menuBgUseTheme", true); @@ -147,6 +151,10 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti return menuBg.get(); } + public String getMenuLayout() { + return menuLayout.get(); + } + public boolean isMenuBackgroundUseTheme() { return menuBgUseTheme.get(); } @@ -194,9 +202,16 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti List list = new ArrayList<>(); list.add(SettingDef.mode(msaa3d) .unavailableWhen(IrisRuntime::isShaderpackRendererActive, MainConfig::irisMsaaReason)); + return list; + } + + /** Everything about the main menu in one group: layout, background, theme colors, clock. */ + public List getMainMenuSettingDefs() { + List list = new ArrayList<>(); + list.add(SettingDef.mode(menuLayout)); list.add(SettingDef.mode(menuBg)); - list.add(SettingDef.bool(menuClockShowSeconds)); - list.add(SettingDef.bool(menuBgUseTheme)); + list.add(SettingDef.bool(menuBgUseTheme).visibleWhen(() -> !"off".equalsIgnoreCase(menuBg.get()))); + list.add(SettingDef.bool(menuClockShowSeconds).visibleWhen(() -> !"hero".equalsIgnoreCase(menuLayout.get()))); return list; } @@ -274,6 +289,7 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti List> list = new ArrayList<>(); list.add(debug); list.add(menuBg); + list.add(menuLayout); list.add(menuClockShowSeconds); list.add(menuBgUseTheme); list.add(msaa3d); diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java index 3c60bc2..18651e4 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java @@ -3,12 +3,14 @@ package dev.loki.lovisual.features.command.impl.world; 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.impl.CommandUtils; import dev.loki.lovisual.features.command.util.CommandOutput; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.misc.Balancer; import dev.loki.lovisual.util.mctiers.MCTiersCache; import dev.loki.lovisual.util.mctiers.MCTiersTier; +import java.util.List; import java.util.Locale; /** @@ -23,6 +25,19 @@ import java.util.Locale; descriptionKey = "command.tier.description" ) public final class TierCommand implements ClientCommand { + private static final List MODES = List.of("vanilla", "uhc", "pot", "nethop", "smp", "sword", "axe", "mace"); + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + if (argIndex == 1) { + return CommandUtils.suggestPlayers(ctx.mc(), token); + } + if (argIndex == 2) { + return MODES.stream().filter(mode -> mode.startsWith(lower)).toList(); + } + return List.of(); + } @Override public boolean execute(CommandContext ctx) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/suggest/SuggestionPlan.java b/mod/src/main/java/dev/loki/lovisual/features/command/suggest/SuggestionPlan.java new file mode 100644 index 0000000..b19b73a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/command/suggest/SuggestionPlan.java @@ -0,0 +1,38 @@ +package dev.loki.lovisual.features.command.suggest; + +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.suggestion.Suggestion; +import com.mojang.brigadier.suggestion.Suggestions; + +import java.util.ArrayList; +import java.util.List; + +/** + * Splits raw command suggestions into pickable completions and a usage-hint placeholder + * (an entry like "%toggle <module>"), so the vanilla chat popup lists only real choices + * and shows the hint as ghost text instead of inserting it. + * + * @param suggestions pickable completions, all sharing the first one's range; null when none + * @param ghostHint placeholder such as "<module>" to show after the cursor, or null + */ +public record SuggestionPlan(Suggestions suggestions, String ghostHint) { + + public static SuggestionPlan of(List raw) { + List real = new ArrayList<>(); + String hint = null; + if (raw != null) { + for (Suggestion s : raw) { + String text = s.getText(); + int open = text.lastIndexOf('<'); + if (open >= 0 && text.endsWith(">")) { + if (hint == null) hint = text.substring(open); + } else { + real.add(s); + } + } + } + if (real.isEmpty()) return new SuggestionPlan(null, hint); + StringRange range = real.get(0).getRange(); + return new SuggestionPlan(new Suggestions(range, real), hint); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java index f304a1b..b830758 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java @@ -297,7 +297,8 @@ public final class ModulesMenuPanel { settingsSmoothScroll = 0.0f; } - record ModuleHit(String id, float x, float y, float w, float h, boolean hasSettings, boolean toggleable) { + record ModuleHit(String id, String title, String description, float x, float y, float w, float h, + boolean hasSettings, boolean toggleable) { } record SettingHit(Setting setting, float x, float y, float w, float h) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java index 60ebccf..98017f2 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java @@ -30,7 +30,7 @@ public enum ModulesMenuResolver { out.add(new ModuleComponent.CardEntry( module.name(), module.getDisplayName(), - locked ? I18n.get("lovisual.policy.locked", ServerPolicyState.reason()) : "", + locked ? I18n.get("lovisual.policy.locked", ServerPolicyState.reason()) : module.getDescription(), bind == null ? "" : bind, !module.getSettings().isEmpty(), module.isEnabled(), diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java index d0ef50a..59affe1 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java @@ -31,6 +31,7 @@ public final class ModulesMenuScreen { float scale = 1.0f; float screenAnim = 0.0f; boolean openTarget = false; float searchAnim = 0.0f; float searchX; float searchY; float searchW; float searchH; float searchBtnX; float searchBtnY; float searchBtnW; float searchBtnH; float searchBtnHoverAnim; + float descAnim; String descTitle; String descText; public ModulesMenuScreen() { for (ModulesMenuCategory c : ModulesMenuCategory.values()) panels.add(new ModulesMenuPanel(c, c.ordinal())); @@ -80,6 +81,7 @@ public final class ModulesMenuScreen { } ModulesMenuChromeRenderer.renderSearchButton(this, mouseX, mouseY, screenAnim, scale); ModulesMenuChromeRenderer.renderSearch(this, screenAnim, scale); + ModulesMenuDescriptionRenderer.render(this, mouseX, mouseY, screenAnim, scale); ModulesMenuChromeRenderer.renderHints(this, screenAnim, scale); ClickGuiRenderer.flushRenderer(); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLines.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLines.java new file mode 100644 index 0000000..1ab0a39 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLines.java @@ -0,0 +1,45 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.ToDoubleFunction; + +/** Word wrapping for the hover description plaque, independent of any renderer. */ +public final class DescriptionLines { + private static final String ELLIPSIS = "..."; + + private DescriptionLines() { + } + + /** + * Greedy word wrap; a single word wider than {@code maxWidth} gets its own line, and when more + * than {@code maxLines} are needed the last line is cut with an ellipsis. + */ + public static List wrap(String text, double maxWidth, ToDoubleFunction measure, int maxLines) { + List lines = new ArrayList<>(); + if (text == null || text.isBlank() || maxLines < 1) return lines; + StringBuilder line = new StringBuilder(); + for (String word : text.trim().split("\\s+")) { + String candidate = line.isEmpty() ? word : line + " " + word; + if (!line.isEmpty() && measure.applyAsDouble(candidate) > maxWidth) { + lines.add(line.toString()); + line.setLength(0); + candidate = word; + } + line.setLength(0); + line.append(candidate); + } + if (!line.isEmpty()) lines.add(line.toString()); + return lines.size() <= maxLines ? lines : truncate(lines, maxLines, maxWidth, measure); + } + + private static List truncate(List lines, int maxLines, double maxWidth, ToDoubleFunction measure) { + List out = new ArrayList<>(lines.subList(0, maxLines)); + String last = out.get(maxLines - 1); + while (last.length() > 1 && measure.applyAsDouble(last + ELLIPSIS) > maxWidth) { + last = last.substring(0, last.length() - 1); + } + out.set(maxLines - 1, last + ELLIPSIS); + return out; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java new file mode 100644 index 0000000..7dc070d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java @@ -0,0 +1,80 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.DescriptionLines; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuStyle; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; + +import java.util.List; + +/** + * Hover plaque above the module columns: shows the description of the module row under the + * cursor and fades out when nothing is hovered. + */ +final class ModulesMenuDescriptionRenderer { + private static final int MAX_LINES = 3; + + private ModulesMenuDescriptionRenderer() { + } + + static void render(ModulesMenuScreen screen, float mouseX, float mouseY, float screenAnim, float scale) { + ModulesMenuPanel.ModuleHit hit = hoveredHit(screen, mouseX, mouseY); + boolean show = hit != null && !hit.description().isBlank(); + if (show) { + screen.descTitle = hit.title(); + screen.descText = hit.description(); + } + screen.descAnim = AnimationUtility.snap( + AnimationUtility.approach(screen.descAnim, show ? 1.0f : 0.0f, AnimationUtility.deltaTime(), 9.0f), + show ? 1.0f : 0.0f, 0.01f); + float alpha = ModulesMenuLayout.easeOutCubic(screenAnim) * ModulesMenuLayout.easeOutCubic(screen.descAnim); + if (alpha <= 0.001f || screen.descText == null) return; + + ModulesMenuChromeRenderer.ensureFonts(); + TextRenderer titleFont = ModulesMenuChromeRenderer.medium; + TextRenderer bodyFont = ModulesMenuChromeRenderer.regular; + float titleSize = 9.0f * scale; + float bodySize = 7.5f * scale; + float pad = 9.0f * scale; + float w = Math.min(screen.areaW * 0.6f, 300.0f * scale); + List lines = DescriptionLines.wrap(screen.descText, w - pad * 2.0f, + s -> ClickGuiRenderer.textWidth(bodyFont, s, bodySize), MAX_LINES); + float titleH = ModulesMenuLayout.textHeight(titleFont, titleSize); + float lineH = ModulesMenuLayout.textHeight(bodyFont, bodySize) + 1.5f * scale; + float h = pad * 2.0f + titleH + 3.0f * scale + lines.size() * lineH; + float x = screen.areaX + (screen.areaW - w) * 0.5f; + float y = Math.max(screen.areaY + 6.0f * scale, panelsTop(screen) - h - 10.0f * scale) + (1.0f - alpha) * 6.0f * scale; + + float radius = 8.0f * scale; + ModulesMenuGlassRenderer.drawLiquidGlass(x, y, w, h, radius, false, alpha >= 0.999f ? 1.0f : alpha, scale); + int veil = ModulesMenuLayout.withAlpha(ModulesMenuStyle.panelBgGlassDark(), alpha * 0.35f); + LayoutRender2D.roundedQuad(x, y, w, h, radius, veil, veil, veil, veil); + ClickGuiRenderer.drawText(titleFont, screen.descTitle, x + pad, y + pad, titleSize, + ModulesMenuLayout.withAlpha(ModulesMenuStyle.text(), alpha), false); + float ty = y + pad + titleH + 3.0f * scale; + int bodyColor = ModulesMenuLayout.withAlpha(ModulesMenuStyle.textMuted(), alpha); + for (String line : lines) { + ClickGuiRenderer.drawText(bodyFont, line, x + pad, ty, bodySize, bodyColor, false); + ty += lineH; + } + } + + private static ModulesMenuPanel.ModuleHit hoveredHit(ModulesMenuScreen screen, float mx, float my) { + if (!ModulesMenuLayout.isInteractive(screen.screenAnim, screen.openTarget)) return null; + for (ModulesMenuPanel panel : screen.panels) { + if (panel.selected != null || !ModulesMenuLayout.inside(mx, my, panel.x, panel.y, panel.w, panel.h)) continue; + for (ModulesMenuPanel.ModuleHit hit : panel.hits) { + if (ModulesMenuLayout.inside(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) return hit; + } + } + return null; + } + + private static float panelsTop(ModulesMenuScreen screen) { + float top = Float.MAX_VALUE; + for (ModulesMenuPanel panel : screen.panels) top = Math.min(top, panel.y); + return top == Float.MAX_VALUE ? screen.areaY : top; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuModuleRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuModuleRenderer.java index 9f36b77..d54399c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuModuleRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuModuleRenderer.java @@ -87,7 +87,8 @@ final class ModulesMenuModuleRenderer { Renderer2D.COLOR.svg("check", checkX, checkY, check, check, SvgRenderOptions.overrideColor(ModulesMenuLayout.withAlpha(ModulesMenuStyle.text(), alpha * (0.10f + 0.90f * enabled)))); } - panel.hits.add(new ModulesMenuPanel.ModuleHit(entry.getId(), x, y, rowW, h, entry.hasSettings(), entry.toggleable())); + panel.hits.add(new ModulesMenuPanel.ModuleHit(entry.getId(), entry.title(), entry.description() == null ? "" : entry.description(), + x, y, rowW, h, entry.hasSettings(), entry.toggleable())); } static void renderModuleRowHover(ModulesMenuPanel panel, float x, float y, float w, float h, diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java index 9cce486..e48143a 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java @@ -1,5 +1,6 @@ package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; +import net.minecraft.client.resources.language.I18n; import dev.loki.lovisual.config.io.ConfigSerializer; import dev.loki.lovisual.config.definition.SettingDef; import dev.loki.lovisual.config.definition.SettingOwner; @@ -64,7 +65,7 @@ public enum MenuSettingsResolver { out.add(new ModuleComponent.CardEntry( widget.getId(), widget.getTitle(), - "", + widget.getDescription(), "", !widget.getSettingDefs().isEmpty(), widget.isEnabled(), @@ -147,7 +148,7 @@ public enum MenuSettingsResolver { return new ModuleComponent.CardEntry( staticHud.getId(), title, - "", + staticHud.getDescription(), "", !staticHud.getSettingDefs().isEmpty(), staticHud.isEnabled(), @@ -156,7 +157,7 @@ public enum MenuSettingsResolver { } private static ModuleComponent.CardEntry uiReadOnlyCard(String id, String title) { - return new ModuleComponent.CardEntry(id, title, "", "", true, false, false); + return new ModuleComponent.CardEntry(id, title, I18n.get("hud." + id + ".description"), "", true, false, false); } private static String mapUiAlias(String id) { 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 9ea7e79..6a12569 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 @@ -166,7 +166,7 @@ public final class MainSettingsComponent { int text = active ? palette.menuHeaderText() : LayoutRender2D.alpha(palette.panelMuted(), 0.72f + 0.20f * hoverAnim); ClickGuiRenderer.drawText( ClickGuiRenderer.getInterMedium(), - category.title, + category.label(), x + 13f * scale, rowY + 6f * scale, 7f * scale, @@ -210,7 +210,7 @@ public final class MainSettingsComponent { LayoutRender2D.alpha(palette.menuLineStrong(), 0.82f), LayoutRender2D.alpha(palette.menuLineLow(), 0.55f) ); - ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), selected.title, rightX + pad, titleY, titleSize, text, false); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), selected.label(), rightX + pad, titleY, titleSize, text, false); contentX = rightX + pad; contentY = rightY + 27f * scale; @@ -287,9 +287,10 @@ public final class MainSettingsComponent { settingsByCategory.clear(); MainConfig config = MainConfig.get(); settingsByCategory.put(Category.IMAGE, buildSettings(config.getImageSettingDefs(), config)); + settingsByCategory.put(Category.MAIN_MENU, buildSettings(config.getMainMenuSettingDefs(), config)); settingsByCategory.put(Category.MISCELLANIOUS, buildSettings(config.getMiscellaneousSettingDefs(), config)); settingsByCategory.put(Category.SECURITY, buildSettings(config.getSecuritySettingDefs(), config)); - List utility = buildSettings(config.getUtilitySettingDefs(), config); + List utility = new java.util.ArrayList<>(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/clickgui/layout/screen/settings/implement/main/MainSettingsModel.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsModel.java index 4a4912e..eba497c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsModel.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsModel.java @@ -1,5 +1,7 @@ package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; + import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; /** @@ -9,6 +11,7 @@ import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; */ enum Category { IMAGE("Image"), + MAIN_MENU("Main menu"), SECURITY("Security"), UTILITY("Utility"), MISCELLANIOUS("Miscellanious"); @@ -18,6 +21,11 @@ enum Category { Category(String title) { this.title = title; } + + /** Localized label; the English title is the fallback. */ + String label() { + return ClickGuiI18n.tr("clickgui.settings.category." + name().toLowerCase(), title); + } } record SettingRow(Setting setting, float anim, float height, float gap) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/BaseHudElement.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/BaseHudElement.java index ea0bbc0..4781220 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/BaseHudElement.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/BaseHudElement.java @@ -152,6 +152,8 @@ public abstract class BaseHudElement implements JsonConfigObject, ConfigNameProv public final String getId() { return id; } public final String getTitle() { return HudElementNamer.translateTitle(title, getTranslationKeyPrefix(), id); } + /** Short settings-card description from {@code hud..description}; empty when none is translated. */ + public final String getDescription() { return HudElementNamer.translateDescription(id); } public boolean isEnabled() { return enabled.get() || previewEnabled; } public void setRuntimeEnabledTransient(boolean value) { enabled.set(value); } public void setPreviewEnabled(boolean value) { previewEnabled = value; } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/impl/HudElementNamer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/impl/HudElementNamer.java index 5aa4ff4..de7372e 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/impl/HudElementNamer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/base/impl/HudElementNamer.java @@ -42,6 +42,14 @@ final class HudElementNamer { return fallback == null ? "" : fallback; } + /** Translated {@code hud..description}, or an empty string when the key is missing. */ + static String translateDescription(String id) { + if (id == null || id.isBlank()) return ""; + String key = "hud." + id + ".description"; + String translated = I18n.get(key); + return translated.equals(key) ? "" : translated; + } + private static String stripOwnerPrefix(String value, String owner) { if (value == null || value.isBlank() || owner == null || owner.isBlank()) return value; String normalizedOwner = normalizePrefix(owner); diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/scripted/ScriptedListHudPanel.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/scripted/ScriptedListHudPanel.java index 455f070..38f2e4c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/scripted/ScriptedListHudPanel.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/scripted/ScriptedListHudPanel.java @@ -36,6 +36,9 @@ public final class ScriptedListHudPanel { public static final Variant KEYBINDS = new Variant( "keybinds", "Keybinds", "M", 102.0f, 18.0f, 5.0f, 22.0f, 22.0f, 3.0f, 3.5f, 8.0f, 15.0f, 6.0f, 18.0f, 8.0f, 1.95f, "Icons", 1.18f); + public static final Variant VOICECHAT = new Variant( + "voicechat", "Voice chat", "K", + 96.0f, 18.0f, 5.0f, 22.0f, 22.0f, 3.0f, 3.5f, 8.0f, 15.0f, 6.0f, 18.0f, 8.0f, 1.95f); public static final float HEADER_HEIGHT = 15.5f; public static final float BODY_Y_OFFSET = 18.5f; public static final float CONTENT_START_Y = 25.0f; diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHud.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHud.java new file mode 100644 index 0000000..e33dea5 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHud.java @@ -0,0 +1,78 @@ +package dev.loki.lovisual.features.gui.hud.draggable.impl.voice; + +import dev.loki.lovisual.config.definition.SettingDef; +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.gui.hud.config.HudBgStyles; +import dev.loki.lovisual.features.gui.hud.config.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.impl.HudPanelLayoutModes; +import dev.loki.lovisual.features.gui.hud.draggable.impl.scripted.ScriptedListHudPanel; +import dev.loki.lovisual.features.gui.hud.registry.HudElementRegister; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.List; + +/** Draggable Simple Voice Chat panel: who is talking right now plus the state of your own microphone. */ +@HudElementRegister(order = 52) +public final class VoiceChatHud extends DraggableHudElement { + + { + defaultLayout(16.0f, 150.0f); + } + + final HudGlobalConfig hud = HudGlobalConfig.get(); + final ScriptedListHudPanel panel = new ScriptedListHudPanel(); + final NumberValue scale = new NumberValue<>("voicechat_scale", 1.0, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX); + final ModeValue layoutMode = new ModeValue("voicechat_layout", "Split Header", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER); + final ModeValue bgEffect = new ModeValue("voicechat_bg_effect", HudBgStyles.AUTO, HudBgStyles.NONE, HudBgStyles.BLUR, HudBgStyles.AUTO); + final NumberValue bgAlpha = new NumberValue<>("voicechat_bg_alpha", 131, 0, 255); + final NumberValue blurAlpha = new NumberValue<>("voicechat_blur_alpha", 255, 0, 255); + final NumberValue maxRows = new NumberValue<>("voicechat_max_rows", 6, 1, 12); + final NumberValue holdMs = new NumberValue<>("voicechat_hold_ms", 350, 100, 1500); + final BooleanValue showSelf = new BooleanValue("voicechat_show_self", true); + + float displayWidth = -1.0f; + float displayHeight = -1.0f; + float visibilityAnim = 0.0f; + + public VoiceChatHud() { + super("voicechat", "VoiceChat", false); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scale)); + defs.add(SettingDef.mode(layoutMode)); + defs.add(SettingDef.number(maxRows)); + defs.add(SettingDef.number(holdMs)); + defs.add(SettingDef.bool(showSelf)); + defs.add(SettingDef.number(bgAlpha)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(() -> HudBgStyles.hasEffect(bgEffect.get()))); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16.0f; + this.y = 150.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx, + float tickDelta, int screenW, int screenH) { + VoiceChatHudRenderer.render(this, renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } + + void setSize(float w, float h) { + setBounds(getX(), getY(), w, h); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHudRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHudRenderer.java new file mode 100644 index 0000000..0adcc51 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/voice/VoiceChatHudRenderer.java @@ -0,0 +1,177 @@ +package dev.loki.lovisual.features.gui.hud.draggable.impl.voice; + +import dev.loki.lovisual.compat.voicechat.VoiceChatBridge; +import dev.loki.lovisual.compat.voicechat.VoiceState; +import dev.loki.lovisual.features.gui.hud.config.HudBgStyles; +import dev.loki.lovisual.features.gui.hud.config.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.draggable.impl.HudPanelLayoutModes; +import dev.loki.lovisual.features.gui.hud.draggable.impl.scripted.ScriptedListHudPanel; +import dev.loki.lovisual.features.gui.hud.draggable.impl.scripted.ScriptedListHudPanel.Palette; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.font.Fonts; +import dev.loki.lovisual.render.engine.text.font.info.FontInfo; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import dev.loki.lovisual.util.player.health.PlayerSkinResolver; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.resources.Identifier; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.UUID; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +/** Builds the rows of {@link VoiceChatHud} and feeds them to the shared scripted list panel. */ +final class VoiceChatHudRenderer { + private static final int TALKING = 0xFF55FF77; + private static final int WHISPER = 0xFFFFD255; + private static final int SILENT = 0xFFFF5555; + private static final float WIDTH_PAD = 30.0f; + + private VoiceChatHudRenderer() { + } + + private record Row(String key, Identifier skin, String name, String status, int statusColor, float alpha) { } + + static void render(VoiceChatHud host, Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx, + float tickDelta, int screenW, int screenH) { + Minecraft mc = Minecraft.getInstance(); + boolean preview = DraggableHudElementRegistry.isForceVisible() || ClientScreen.current() instanceof ChatScreen; + if (mc == null || (mc.player == null && !preview)) { host.setSize(0f, 0f); return; } + List rows = collectRows(host, mc, preview); + host.visibilityAnim = HudRenderUtil.animateVisibility(host.visibilityAnim, !rows.isEmpty()); + if (rows.isEmpty() && host.visibilityAnim <= 0.0f) { host.setSize(0f, 0f); return; } + + TextRenderer header = font("Inter", FontInfo.Type.Bold, textRenderer); + TextRenderer body = font("OnestMedium", FontInfo.Type.Regular, textRenderer); + TextRenderer icons = font("IconsNur", FontInfo.Type.Regular, textRenderer); + float base = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(host.scale); + float fontScale = 0.98f * (host.hud.getFontSize() / 18.0f); + float iconH = height(icons, fontScale); + float titleH = height(header, fontScale); + float rowH = height(body, fontScale); + float titleW = width(header, I18n.get("hud.voicechat.title"), fontScale); + float labelW = width(body, "Active:", fontScale * 0.92f); + float countW = width(body, Integer.toString(rows.size()), fontScale * 0.92f); + float maxW = Math.max(ScriptedListHudPanel.VOICECHAT.minWidth() * base, + (ScriptedListHudPanel.VOICECHAT.titleTextX() * base) + titleW + labelW + countW + 34.0f * base); + for (Row r : rows) { + maxW = Math.max(maxW, width(body, r.name(), fontScale) + width(body, r.status(), fontScale) + WIDTH_PAD * base); + } + float targetH = ScriptedListHudPanel.CONTENT_START_Y * base + rows.size() * ScriptedListHudPanel.ROW_STEP * base; + host.displayWidth = HudRenderUtil.animateDimension(host.displayWidth, maxW); + host.displayHeight = HudRenderUtil.animateDimension(host.displayHeight, rows.isEmpty() ? 0f : targetH); + host.setSize(host.displayWidth, host.displayHeight); + + float s = Math.max(0.0f, HudRenderUtil.visibilityScale(host.visibilityAnim)); + float w = host.displayWidth * s; + float h = host.displayHeight * s; + if (w <= 0.0f || h <= 0.0f) return; + Palette palette = palette(host.bgAlpha.get()); + boolean blur = HudBgStyles.hasEffect(host.bgEffect.get()); + host.panel.render(renderer, textRenderer, ctx, tickDelta, new ScriptedListHudPanel.Panel( + ScriptedListHudPanel.VOICECHAT, palette, + host.getX() + (host.displayWidth - w) * 0.5f, host.getY() + (host.displayHeight - h) * 0.5f, w, h, + s, base * s, fontScale * s, iconH * s, titleH * s, rowH * s, labelW * s, countW * s, rows.size(), + blur, Math.min(1.0f, host.blurAlpha.get() / 255.0f * 1.15f), palette.counter(), + HudPanelLayoutModes.current(host.layoutMode), toPanelRows(rows, palette))); + } + + private static List collectRows(VoiceChatHud host, Minecraft mc, boolean preview) { + if (!VoiceChatBridge.isInstalled()) { + return List.of(new Row("missing", null, I18n.get("hud.voicechat.not_found"), "", SILENT, 1.0f)); + } + VoiceState state = VoiceChatBridge.state(); + long hold = host.holdMs.get(); + List rows = new java.util.ArrayList<>(); + if (host.showSelf.get() && mc.player != null) { + VoiceState.MicStatus mic = state.micStatus(hold); + rows.add(new Row("self", PlayerSkinResolver.resolveProfileSkin(mc.player.getGameProfile()), + mc.player.getName().getString(), I18n.get("hud.voicechat.status." + mic.name().toLowerCase()), + statusColor(mic), 1.0f)); + } + for (VoiceState.Speaker sp : state.speakers(hold, host.maxRows.get())) { + PlayerInfo info = mc.getConnection() != null ? mc.getConnection().getPlayerInfo(sp.id()) : null; + String name = info != null && info.getProfile() != null ? info.getProfile().name() : shortId(sp.id()); + Identifier skin = info != null ? PlayerSkinResolver.resolveProfileSkin(info.getProfile()) : null; + rows.add(new Row(sp.id().toString(), skin, name, sp.whisper() ? I18n.get("hud.voicechat.whisper") : "", + sp.whisper() ? WHISPER : TALKING, state.level(sp.id(), hold) * 0.6f + 0.4f)); + } + if (rows.isEmpty() && preview && mc.player != null) { + rows.add(new Row("example", PlayerSkinResolver.resolveProfileSkin(mc.player.getGameProfile()), + "Example Player", "", TALKING, 1.0f)); + } + return rows; + } + + private static List> toPanelRows(List rows, Palette p) { + List> out = ScriptedListHudPanel.rows(); + for (Row r : rows) { + int alpha = Math.round(255.0f * r.alpha()); + out.add(ScriptedListHudPanel.row(r.key(), r.skin() != null ? "head" : "", ScriptedListHudPanel.idString(r.skin()), + List.of(ScriptedListHudPanel.textPart(r.name(), withAlpha(p.text(), alpha), 0.0f)), + r.status(), withAlpha(r.statusColor(), alpha), withAlpha(p.muted(), Math.max(26, alpha - 120)), r.alpha())); + } + return out; + } + + private static int statusColor(VoiceState.MicStatus mic) { + return switch (mic) { + case TALKING -> TALKING; + case WHISPERING -> WHISPER; + case MUTED, DISABLED, DISCONNECTED -> SILENT; + default -> theme().textMuted(); + }; + } + + /** Theme-driven palette, the same recipe the other list panels use in theme mode. */ + private static Palette palette(int alpha) { + int window = HudRenderUtil.setAlpha(theme().windowBg(), alpha); + int header = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().windowHeader(), theme().surface(), 0.18f), Math.min(255, alpha + 14)); + int surface = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().surface(), theme().windowBg(), 0.22f), Math.min(255, alpha + 6)); + int deep = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.42f), Math.min(255, alpha + 18)); + int headerRight = HudRenderUtil.mixColor(surface, header, 0.52f); + int bodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + int muted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + return new Palette(HudRenderUtil.mixColor(header, window, 0.22f), headerRight, + HudRenderUtil.mixColor(window, surface, 0.24f), bodyRight, + HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), Math.min(255, Math.max(182, alpha + 36))), + theme().textPrimary(), muted, HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f), + HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f), HudRenderUtil.scaleAlpha(muted, 0.68f), + HudRenderUtil.mixColor(HudRenderUtil.mixColor(headerRight, bodyRight, 0.5f), theme().accent(), 0.14f)); + } + + private static int withAlpha(int rgb, int alpha) { + return (Math.max(0, Math.min(255, alpha)) << 24) | (rgb & 0x00FFFFFF); + } + + private static String shortId(UUID id) { + return id.toString().substring(0, 8); + } + + private static TextRenderer font(String family, FontInfo.Type type, TextRenderer fallback) { + TextRenderer r = Fonts.renderer(family, type, TextRenderer.get()); + return r != null ? r : fallback; + } + + private static float height(TextRenderer r, float size) { + r.begin(size, true, false); + float v = (float) r.getHeight(false); + r.end(); + return v; + } + + private static float width(TextRenderer r, String text, float size) { + r.begin(size, true, false); + float v = (float) r.getWidth(text, false); + r.end(); + return v; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/buttons/state/ButtonQueue.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/buttons/state/ButtonQueue.java index cf3c36d..47a1461 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/buttons/state/ButtonQueue.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/buttons/state/ButtonQueue.java @@ -96,7 +96,7 @@ public final class ButtonQueue { public static void captureTooltip(AbstractWidget widget, int mouseX, int mouseY) { if (widget == null) return; BetterTooltips tooltips = BetterTooltips.get(); - if (tooltips == null || !tooltips.useCustomGuiTooltips()) { + if (tooltips == null || !tooltips.useButtonTooltips()) { return; } WidgetTooltipHolder state = ((ClickableWidgetAccessor) widget).lovisual$getTooltipState(); diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java index 6e57716..c3926a2 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java @@ -128,10 +128,14 @@ public final class BetterTooltips extends AbstractHudElement { for (ConfigValue v : vs) { SettingDef d = settingDef(v); if (d != null) defs.add(d); } } - public boolean useCustomGuiTooltips() { BetterButtons b = BetterButtons.get(); if (b != null && b.useUiButtons()) return true; return isEnabled(); } - public boolean useCustomItemTooltips() { BetterButtons b = BetterButtons.get(); if (b != null && b.useUiButtons()) return true; return itemTooltipEnabled.get(); } - public boolean isItemInfoColorizeEnabled() { return itemInfoColorize.get(); } - public boolean isTooltipAlphaEnabled() { return anyTooltipsEnabled(); } + /** Every custom tooltip feature is gated by the module toggle: when it is off, tooltips are fully vanilla. */ + public boolean useCustomGuiTooltips() { return isEnabled(); } + public boolean useCustomItemTooltips() { return isEnabled() && itemTooltipEnabled.get(); } + /** BetterButtons draws its own button hints through this panel even while the module is off. */ + public boolean useButtonTooltips() { BetterButtons b = BetterButtons.get(); return (b != null && b.useUiButtons()) || isEnabled(); } + public boolean isItemInfoColorizeEnabled() { return isEnabled() && itemInfoColorize.get(); } + public boolean isTooltipAlphaEnabled() { return isEnabled(); } + public boolean isShulkerPreviewActive() { return isEnabled() && shulkerPreviewEnabled.get(); } public int tooltipColor() { return (TOOLTIP_BG_ALPHA << 24) | 0x00101114; } public boolean isShulkerPreviewEnabled() { return shulkerPreviewEnabled.get(); } public void setShulkerPreviewEnabled(boolean v) { shulkerPreviewEnabled.set(v); saveConfig(); } @@ -154,7 +158,6 @@ public final class BetterTooltips extends AbstractHudElement { public float shulkerPreviewSlotGap() { return SHULKER_SLOT_GAP; } public float shulkerPreviewPadding() { return SHULKER_PADDING; } - private boolean anyTooltipsEnabled() { if (useCustomGuiTooltips()) return true; if (itemTooltipEnabled.get()) return true; return shulkerPreviewEnabled.get(); } private String bindingName(KeyBindValue v) { return name() + ":" + v.getName(); } private void registerBind(KeyBindValue v) { String n = bindingName(v); KeyManager.unregisterAll(n); diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/ShulkerPreviewCache.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/ShulkerPreviewCache.java index d93f638..e2dcff1 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/ShulkerPreviewCache.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/ShulkerPreviewCache.java @@ -26,7 +26,7 @@ final class ShulkerPreviewCache { static PreviewLayout buildLayout() { BetterTooltips self = BetterTooltips.get(); - if (self == null || !self.isShulkerPreviewEnabled() || !self.isShulkerPreviewHeld()) return null; + if (self == null || !self.isShulkerPreviewActive() || !self.isShulkerPreviewHeld()) return null; ItemStack toolStack = TooltipState.getStack(); GuiGraphicsExtractor ctx = TooltipState.getCtx(); if (toolStack == null || toolStack.isEmpty() || !isShulkerBox(toolStack) || ctx == null) return null; diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipColors.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipColors.java new file mode 100644 index 0000000..304c7b1 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipColors.java @@ -0,0 +1,22 @@ +package dev.loki.lovisual.features.gui.hud.nondraggable.impl.tooltips; + +/** Keeps vanilla tooltip text readable on the themed panel. */ +final class TooltipColors { + private static final int VANILLA_BLUE = 0x5555FF; + private static final int VANILLA_DARK_BLUE = 0x0000AA; + + private TooltipColors() { + } + + /** + * Vanilla paints creative-tab lines pure blue, which is hard to read on a dark themed panel; + * those two shades are replaced by the theme accent, every other color is kept. + */ + static int readable(int argb, int accentArgb) { + int rgb = argb & 0x00FFFFFF; + if (rgb == VANILLA_BLUE || rgb == VANILLA_DARK_BLUE) { + return 0xFF000000 | (accentArgb & 0x00FFFFFF); + } + return argb; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipState.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipState.java index 08bbf6e..4664792 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipState.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/state/TooltipState.java @@ -111,6 +111,7 @@ final class TooltipState { static List convertOrdered(List src) { List out = new ArrayList<>(); int fallback = Theme.theme().textPrimary(); + int accent = Theme.theme().accent(); for (FormattedCharSequence line : src) { StringBuilder sb = new StringBuilder(); int[] color = new int[]{0}; @@ -122,7 +123,7 @@ final class TooltipState { return true; }); if (sb.length() == 0) sb.append(' '); - int c = color[0] != 0 ? color[0] : fallback; + int c = color[0] != 0 ? TooltipColors.readable(color[0], accent) : fallback; out.add(new TooltipLine(sb.toString(), c)); } return out; @@ -149,7 +150,7 @@ final class TooltipState { private static boolean shouldShowHint(boolean isShulker) { if (!isShulker) return false; BetterTooltips b = BetterTooltips.get(); - if (b == null || !b.isShulkerPreviewEnabled()) return false; + if (b == null || !b.isShulkerPreviewActive()) return false; if (!ShulkerPreviewCache.hasShulkerContents(stack)) return false; return !b.isShulkerPreviewHeld(); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/MainMenus.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/MainMenus.java new file mode 100644 index 0000000..1fc7f7a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/MainMenus.java @@ -0,0 +1,41 @@ +package dev.loki.lovisual.features.gui.mainmenu; + +import dev.loki.lovisual.config.i18n.MainConfig; +import dev.loki.lovisual.features.gui.mainmenu.hero.LoVisualHeroMenuScreen; +import dev.loki.lovisual.util.logging.DebugLog; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; + +/** Picks the main menu implementation from {@code menuLayout} and owns the vanilla fallback. */ +public final class MainMenus { + private MainMenus() { + } + + public static Screen create() { + MainConfig cfg = MainConfig.get(); + boolean hero = cfg != null && "hero".equalsIgnoreCase(cfg.getMenuLayout()); + return hero ? new LoVisualHeroMenuScreen() : new LoVisualMainMenuScreen(); + } + + public static boolean isMainMenu(Screen screen) { + return screen instanceof LoVisualMainMenuScreen || screen instanceof LoVisualHeroMenuScreen; + } + + public static boolean shouldUseVanillaTitleScreen() { + return LoVisualMainMenuScreen.shouldUseVanillaTitleScreen(); + } + + /** Shows the vanilla title screen; a non-null error also disables the custom menu for this session. */ + public static void fallbackToVanilla(Throwable error) { + if (error != null) { + LoVisualMainMenuScreen.forceVanillaTitleScreen = true; + DebugLog.errorOnce("main_menu_vanilla_fallback", "Main menu failed, switching to vanilla title screen", error); + } + Minecraft mc = Minecraft.getInstance(); + if (mc != null && !(ClientScreen.current() instanceof TitleScreen)) { + ClientScreen.show(mc, new TitleScreen(false)); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/LoVisualHeroMenuScreen.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/LoVisualHeroMenuScreen.java new file mode 100644 index 0000000..792fb64 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/LoVisualHeroMenuScreen.java @@ -0,0 +1,200 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero; + +import dev.loki.lovisual.features.account.AccountConfig; +import dev.loki.lovisual.features.gui.mainmenu.LoVisualAddonManagerScreen; +import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil; +import dev.loki.lovisual.features.gui.mainmenu.MainMenus; +import dev.loki.lovisual.features.gui.mainmenu.alt.LoVisualAltManagerScreen; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout; +import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroButtonPainter; +import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroMenuRenderer; +import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint; +import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroWidgetPainter; +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.core.context.ViewportContext; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; +import net.minecraft.client.gui.screens.options.OptionsScreen; +import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraft.util.Util; + +import java.time.LocalTime; +import java.util.List; + +import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.easeOutCubic; + +/** + * "Hero" main-menu layout: greeting and big title on the left, account card and clock widget on + * the top corners. Reuses the shared shader background, the alt manager and the global themes. + */ +public final class LoVisualHeroMenuScreen extends Screen { + private static final long APPEAR_MS = 700L; + private static final int THEME_DOTS = 6; + + private final float[] hover = new float[HeroLayout.BUTTON_COUNT]; + private float accountHover; + private int hoveredDot = -1; + private int hoveredButton = -1; + private boolean hoveredAccount; + private boolean initialized; + private long openTime; + private long lastTime; + private Layout layout; + private float appear; + + public LoVisualHeroMenuScreen() { + super(Component.literal("LoVisual")); + } + + @Override + protected void init() { + initialized = false; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor ctx, int mouseX, int mouseY, float delta) { + Minecraft mc = minecraft; + if (mc == null) return; + if (MainMenus.shouldUseVanillaTitleScreen() || !HeroMenuRenderer.background(mc)) { + MainMenus.fallbackToVanilla(null); + return; + } + long now = Util.getMillis(); + if (!initialized) { + initialized = true; + openTime = now; + lastTime = now; + } + float dt = Mth.clamp((now - lastTime) / 1000f, 0f, 0.1f); + lastTime = now; + appear = easeOutCubic(Mth.clamp((now - openTime) / (float) APPEAR_MS, 0f, 1f)); + try { + ViewportContext.beginUnscaledLogical(ctx); + updateLayout(mc); + updateHover(toFixed(mouseX, mc), toFixed(mouseY, mc), dt); + Renderer2D.COLOR.begin(); + draw(ctx, mc); + Renderer2D.COLOR.render(); + } catch (Throwable t) { + MainMenus.fallbackToVanilla(t); + } finally { + ViewportContext.end(ctx); + } + } + + private void updateLayout(Minecraft mc) { + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + float rise = (1f - appear) * 18f * HeroLayout.scale(MainMenuTextUtil.virtualHeight(fbw, fbh)); + layout = HeroLayout.compute(MainMenuTextUtil.virtualWidth(fbw, fbh), MainMenuTextUtil.virtualHeight(fbw, fbh), + dots().size(), rise, HeroPaint.metrics()); + } + + private void updateHover(float mx, float my, float dt) { + boolean live = appear > 0.8f; + hoveredButton = live ? HeroLayout.buttonAt(layout, mx, my) : -1; + hoveredDot = live ? HeroLayout.dotAt(layout, dots().size(), mx, my) : -1; + hoveredAccount = live && layout.account().contains(mx, my); + float step = Math.min(1f, dt * 12f); + for (int i = 0; i < hover.length; i++) { + hover[i] = Mth.lerp(step, hover[i], hoveredButton == i ? 1f : 0f); + } + accountHover = Mth.lerp(step, accountHover, hoveredAccount ? 1f : 0f); + } + + private void draw(GuiGraphicsExtractor ctx, Minecraft mc) { + float w = MainMenuTextUtil.virtualWidth(mc.getWindow().getWidth(), mc.getWindow().getHeight()); + float h = MainMenuTextUtil.virtualHeight(mc.getWindow().getWidth(), mc.getWindow().getHeight()); + String name = accountName(mc); + boolean needsAuth = AccountConfig.get().getMicrosoftAuthorizationRequiredAccount() != null; + HeroMenuRenderer.vignette(w, h, appear); + HeroMenuRenderer.account(ctx, layout, appear, accountHover, name, needsAuth); + HeroWidgetPainter.draw(layout, appear, dots(), Theme.currentId(), hoveredDot, LocalTime.now()); + HeroMenuRenderer.hero(layout, appear, name); + for (int i = 0; i < HeroLayout.BUTTON_COUNT; i++) { + HeroButtonPainter.draw(layout, i, appear, hover[i], needsAuth && i == HeroLayout.ACCOUNTS); + } + HeroMenuRenderer.footer(layout, appear, mc.getLaunchedVersion()); + } + + private static List dots() { + List presets = Themes.presetEntries(); + return presets.subList(0, Math.min(THEME_DOTS, presets.size())); + } + + private static String accountName(Minecraft mc) { + String active = AccountConfig.get().getActiveAccountName(); + if (active != null && !active.isBlank()) return active; + return mc.getUser() != null ? mc.getUser().getName() : "LoVisual"; + } + + private static float toFixed(float screen, Minecraft mc) { + float scale = HudScale.scale(mc.getWindow().getWidth(), mc.getWindow().getHeight()); + return scale <= 0f ? screen : screen * mc.getWindow().getGuiScale() / scale; + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + Minecraft mc = minecraft; + if (click.button() != 0 || mc == null || layout == null || appear <= 0.8f) return true; + float mx = toFixed((float) click.x(), mc); + float my = toFixed((float) click.y(), mc); + int dot = HeroLayout.dotAt(layout, dots().size(), mx, my); + if (dot >= 0) { + Theme.setTheme(dots().get(dot).id()); + } else if (layout.account().contains(mx, my)) { + open(mc, HeroLayout.ACCOUNTS); + } else { + int button = HeroLayout.buttonAt(layout, mx, my); + if (button >= 0) open(mc, button); + } + return true; + } + + private void open(Minecraft mc, int button) { + switch (button) { + case HeroLayout.MULTIPLAYER -> ClientScreen.show(mc, new JoinMultiplayerScreen(this)); + case HeroLayout.SINGLEPLAYER -> ClientScreen.show(mc, new SelectWorldScreen(this)); + case HeroLayout.ACCOUNTS -> ClientScreen.show(mc, new LoVisualAltManagerScreen(this, + AccountConfig.get().getMicrosoftAuthorizationRequiredAccount() != null)); + case HeroLayout.ADDONS -> ClientScreen.show(mc, new LoVisualAddonManagerScreen(this)); + case HeroLayout.OPTIONS -> ClientScreen.show(mc, new OptionsScreen(this, mc.options, false)); + case HeroLayout.QUIT -> mc.stop(); + default -> { } + } + } + + @Override + public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + } + + @Override + public boolean keyPressed(KeyEvent input) { + return input.key() == 256 || super.keyPressed(input); + } + + @Override + public boolean shouldCloseOnEsc() { + return false; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/DayCycle.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/DayCycle.java new file mode 100644 index 0000000..228b8d5 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/DayCycle.java @@ -0,0 +1,31 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.logic; + +/** Time-of-day helpers for the hero menu clock widget. */ +public final class DayCycle { + public static final int MORNING = 0; + public static final int DAY = 1; + public static final int EVENING = 2; + public static final int NIGHT = 3; + + private DayCycle() { + } + + public static int period(int hour) { + if (hour >= 5 && hour < 12) return MORNING; + if (hour >= 12 && hour < 17) return DAY; + if (hour >= 17 && hour < 22) return EVENING; + return NIGHT; + } + + /** Fraction of the day elapsed, in [0, 1). */ + public static float progress(int hour, int minute) { + return (hour * 60 + minute) / 1440.0f; + } + + /** Centre x of the bar thumb; the thumb never leaves the rounded bar ends. */ + public static float thumbX(float barX, float barW, float barH, float progress) { + float r = barH * 0.5f; + float p = Math.max(0f, Math.min(1f, progress)); + return barX + r + p * (barW - r * 2f); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLayout.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLayout.java new file mode 100644 index 0000000..bd40303 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLayout.java @@ -0,0 +1,130 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.logic; + +/** + * Geometry of the hero main menu, computed from the logical viewport and measured text heights, + * so lines never overlap whatever the resolution or GUI scale. Free of Minecraft types. + */ +public final class HeroLayout { + public static final int BUTTON_COUNT = 6; + public static final int MULTIPLAYER = 0; + public static final int SINGLEPLAYER = 1; + public static final int ACCOUNTS = 2; + public static final int ADDONS = 3; + public static final int OPTIONS = 4; + public static final int QUIT = 5; + private static final float FONT_UNIT_PX = 12f; + private static final float MIN_K = 0.45f; + + /** Font sizes in logical pixels. */ + public record Sizes(float tag, float title, float sub, float label, float small, float footer, float time) { + } + + /** @param titleY1 first title line; @param titleY2 second title line */ + public record Layout(float k, Rect account, float avatarSize, Rect widget, float pad, + float dotsStartX, float dotY, float dotSpacing, float dotRadius, + Rect bar, Rect[] buttons, Sizes sizes, + float heroX, float tagY, float titleY1, float titleY2, float subY, + float footerY, float footerLine) { + } + + private HeroLayout() { + } + + public static float scale(float viewportHeight) { + return Math.max(0.7f, viewportHeight / 1080f * 1.25f); + } + + /** Converts logical pixels to the text renderer's size units. */ + public static float font(float px) { + return px / FONT_UNIT_PX; + } + + /** @param rise downward slide offset while fading in */ + public static Layout compute(float width, float height, int dotCount, float rise, TextMetrics m) { + float k = scale(height); + Layout l = build(width, height, dotCount, rise, m, k); + for (int i = 0; i < 3 && !fits(l); i++) { + k = Math.max(MIN_K, k * 0.92f); + l = build(width, height, dotCount, rise, m, k); + } + return l; + } + + private static boolean fits(Layout l) { + Rect last = l.buttons()[QUIT]; + return last.y() + last.h() + 14f * l.k() <= l.footerY() || l.k() <= MIN_K; + } + + private static Layout build(float width, float height, int dotCount, float rise, TextMetrics m, float k) { + Sizes s = new Sizes(9.5f * k, 26f * k, 11f * k, 12.5f * k, 11.5f * k, 9f * k, 16f * k); + float avatar = 30f * k; + float topY = 24f * k + rise * 0.3f; + Rect account = new Rect(32f * k, topY, avatar + 140f * k, avatar); + + float pad = 10f * k; + float ww = 215f * k; + float timeH = m.height(s.time()); + float barH = 10f * k; + float wh = pad + timeH + 12f * k + barH + pad; + Rect widget = new Rect(width - ww - 24f * k, topY, ww, wh); + float spacing = 13f * k; + float dotsStart = widget.x() + ww - pad - dotCount * spacing + 6f * k; + float dotY = widget.y() + pad + timeH * 0.5f; + Rect bar = new Rect(widget.x() + pad, widget.y() + wh - pad - barH, ww - pad * 2f, barH); + + float footerLine = m.height(s.footer()) + 3f * k; + float footerY = height - 26f * k - footerLine * 3f; + float tagH = m.height(s.tag()); + float titleH = m.height(s.title()); + float subH = m.height(s.sub()); + float buttonH = Math.max(38f * k, m.height(s.label()) + 22f * k); + float gap = 8f * k; + float tagGap = 10f * k; + float subGap = 14f * k; + float buttonsGap = 22f * k; + float blockH = tagH + tagGap + titleH * 2f + 2f * k + subGap + subH + buttonsGap + buttonH * 5f + gap * 4f; + + float top = account.y() + avatar + 30f * k; + float room = footerY - 14f * k - top; + float heroY = top + Math.max(0f, (room - blockH) * 0.42f) + rise; + float heroX = width * 0.045f; + float titleY1 = heroY + tagH + tagGap; + float titleY2 = titleY1 + titleH + 2f * k; + float subY = titleY2 + titleH + subGap; + Rect[] buttons = buttons(heroX, subY + subH + buttonsGap, k, buttonH, gap); + return new Layout(k, account, avatar, widget, pad, dotsStart, dotY, spacing, 4.2f * k, bar, buttons, s, + heroX, heroY, titleY1, titleY2, subY, footerY, footerLine); + } + + private static Rect[] buttons(float x, float top, float k, float h, float gap) { + float w = 290f * k; + Rect[] out = new Rect[BUTTON_COUNT]; + for (int i = 0; i <= ADDONS; i++) { + out[i] = new Rect(x, top + i * (h + gap), w, h); + } + float rowY = top + (ADDONS + 1) * (h + gap); + float optionsW = (w - gap) * 0.55f; + out[OPTIONS] = new Rect(x, rowY, optionsW, h); + out[QUIT] = new Rect(x + optionsW + gap, rowY, w - gap - optionsW, h); + return out; + } + + /** Index of the button under the cursor, or -1. */ + public static int buttonAt(Layout layout, float mx, float my) { + for (int i = 0; i < layout.buttons().length; i++) { + if (layout.buttons()[i].contains(mx, my)) return i; + } + return -1; + } + + /** Index of the theme dot under the cursor, or -1. */ + public static int dotAt(Layout layout, int dotCount, float mx, float my) { + float r = layout.dotRadius() + 3f * layout.k(); + for (int i = 0; i < dotCount; i++) { + float dx = mx - (layout.dotsStartX() + i * layout.dotSpacing()); + float dy = my - layout.dotY(); + if (dx * dx + dy * dy <= r * r) return i; + } + return -1; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/Rect.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/Rect.java new file mode 100644 index 0000000..b615872 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/Rect.java @@ -0,0 +1,12 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.logic; + +/** Axis-aligned rectangle in the fixed logical viewport. */ +public record Rect(float x, float y, float w, float h) { + public boolean contains(float px, float py) { + return px >= x && px <= x + w && py >= y && py <= y + h; + } + + public Rect offset(float dx, float dy) { + return new Rect(x + dx, y + dy, w, h); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/TextMetrics.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/TextMetrics.java new file mode 100644 index 0000000..380b8d1 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/TextMetrics.java @@ -0,0 +1,7 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.logic; + +/** Measures text in logical pixels so the layout can be built from real glyph sizes. */ +public interface TextMetrics { + /** Line height of the bold UI font at {@code px} logical pixels. */ + float height(float px); +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroButtonPainter.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroButtonPainter.java new file mode 100644 index 0000000..da5706c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroButtonPainter.java @@ -0,0 +1,59 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.paint; + +import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.Rect; +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import net.minecraft.client.resources.language.I18n; + +import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.*; + +/** Hero menu buttons: accent primary, dark glass secondaries, a red-tinted quit, icon on the left. */ +public final class HeroButtonPainter { + private static final String[] LABEL_KEYS = {"multiplayer", "singleplayer", "accounts", "addons", "options", "quit"}; + private static final String[] ICONS = {"swords", "tree-deciduous", "user", "package", "columns-3-cog", "cross"}; + + private HeroButtonPainter() { + } + + public static void draw(Layout l, int index, float a, float hover, boolean badge) { + float k = l.k(); + Rect r = l.buttons()[index]; + float rad = 9f * k; + boolean compact = index >= HeroLayout.OPTIONS; + fill(r, rad, k, a, hover, index); + + float iconSize = (compact ? 14f : 17f) * k; + float iconX = r.x() + (compact ? 13f : 16f) * k + iconSize * 0.5f; + float nudge = hover * 1.2f * k; + icon(ICONS[index], iconX, r.y() + r.h() * 0.5f - nudge * 0.2f, iconSize + nudge, argb(0xFFFFFF, a * (0.85f + hover * 0.15f))); + + TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get()); + float px = (compact ? l.sizes().small() : l.sizes().label()); + float textX = iconX + iconSize * 0.5f + (compact ? 8f : 11f) * k; + textCentered(bold, I18n.get("screen.lovisual.hero_menu." + LABEL_KEYS[index]), textX, r.y(), r.h(), px, argb(0xFFFFFF, a)); + if (badge) { + Renderer2D.COLOR.circle(r.x() + r.w() - 14f * k, r.y() + r.h() * 0.5f, 3.6f * k, argb(0xFFB74D, a)); + } + } + + private static void fill(Rect r, float rad, float k, float a, float hover, int index) { + int accent = Theme.theme().accent(); + if (index == HeroLayout.MULTIPLAYER) { + Renderer2D.COLOR.roundedRectSoftShadow(r.x(), r.y() + 3f * k, r.w(), r.h(), rad, 18f * k, 0.05f + hover * 0.06f, argb(accent, a * 0.55f)); + int top = argb(mix(accent, 0xFFFFFF, 0.10f + hover * 0.12f), a); + int bottom = argb(mix(accent, 0x000000, 0.10f - hover * 0.04f), a); + Renderer2D.COLOR.roundedRectGradientQuad(r.x(), r.y(), r.w(), r.h(), rad, top, top, bottom, bottom); + return; + } + boolean quit = index == HeroLayout.QUIT; + int base = quit ? mix(0x1C1E26, 0xB0303C, hover * 0.8f) : mix(0x1C1E26, 0x2E3240, hover); + Renderer2D.COLOR.roundedRectSoftShadow(r.x(), r.y() + 2f * k, r.w(), r.h(), rad, 10f * k, 0.03f, argb(0x000000, a * 0.4f)); + Renderer2D.COLOR.roundedRect(r.x(), r.y(), r.w(), r.h(), rad, argb(base, a * (0.76f + hover * 0.16f))); + Renderer2D.COLOR.roundedRectStrokeGradient(r.x(), r.y(), r.w(), r.h(), rad, 1f, + argb(0xFFFFFF, a * (0.10f + hover * 0.14f)), argb(0xFFFFFF, a * (0.03f + hover * 0.05f)), 90f); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroMenuRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroMenuRenderer.java new file mode 100644 index 0000000..6ed535c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroMenuRenderer.java @@ -0,0 +1,85 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.paint; + +import dev.loki.lovisual.config.i18n.MainConfig; +import dev.loki.lovisual.features.account.SkinManager; +import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.Rect; +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.postprocess.render.MenuBackgroundRenderer; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import dev.loki.lovisual.render.helpers.player.PlayerHeadRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.resources.language.I18n; + +import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.*; + +/** Draw pass of the hero menu: background, vignette, account card, hero text, buttons and footer. */ +public final class HeroMenuRenderer { + private HeroMenuRenderer() { + } + + /** Draws the shader background; returns false when the custom menu is switched off. */ + public static boolean background(Minecraft mc) { + MainConfig cfg = MainConfig.get(); + String mode = cfg != null ? cfg.getMenuBackgroundMode() : "off"; + if (mode == null || mode.equalsIgnoreCase("off")) return false; + MenuBackgroundRenderer.render(mc, mode.equalsIgnoreCase("aurora")); + return true; + } + + public static void vignette(float w, float h, float a) { + int dark = argb(0x0A0C10, a * 0.88f); + int mid = argb(0x0A0C10, a * 0.45f); + int clear = argb(0x0A0C10, 0f); + Renderer2D.COLOR.quad(0, 0, w * 0.27f, h, dark, mid, mid, dark); + Renderer2D.COLOR.quad(w * 0.27f, 0, w * 0.27f, h, mid, clear, clear, mid); + } + + public static void account(GuiGraphicsExtractor ctx, Layout l, float a, float hover, String name, boolean needsAuth) { + float k = l.k(); + Rect r = l.account(); + int accent = Theme.theme().accent(); + Renderer2D.COLOR.roundedRect(r.x(), r.y(), l.avatarSize(), l.avatarSize(), 6f * k, argb(0xFFFFFF, a * (0.07f + hover * 0.07f))); + PlayerHeadRenderer.drawRounded(ctx, r.x(), r.y(), l.avatarSize(), 6f * k, SkinManager.getSkin(name), + new RenderColor(argb(0xFFFFFF, a)), true, new RenderColor(argb(0xFFFFFF, a * 0.14f)), 0.8f, false); + TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get()); + TextRenderer medium = MainMenuTextUtil.interMedium(bold); + float tx = r.x() + l.avatarSize() + l.sizes().footer(); + String caption = needsAuth ? I18n.get("screen.lovisual.hero_menu.sign_in") : I18n.get("screen.lovisual.hero_menu.account"); + float capH = height(medium, 8.5f * k); + float nameH = height(bold, 11f * k); + float top = r.y() + (l.avatarSize() - capH - nameH - 2f * k) * 0.5f; + text(medium, caption, tx, top, 8.5f * k, needsAuth ? argb(0xFFB74D, a) : argb(0xFFFFFF, a * 0.55f)); + text(bold, name, tx, top + capH + 2f * k, 11f * k, argb(0xFFFFFF, a)); + if (needsAuth) { + Renderer2D.COLOR.circle(r.x() + l.avatarSize() - 2f * k, r.y() + 2f * k, 3.4f * k, argb(0xFFB74D, a)); + } + if (hover > 0.01f) { + Renderer2D.COLOR.roundedRectStrokeGradient(r.x() - 4f * k, r.y() - 4f * k, r.w() + 8f * k, r.h() + 8f * k, l.sizes().footer(), 1f, + argb(accent, a * hover * 0.5f), argb(accent, a * hover * 0.2f), 90f); + } + } + + public static void hero(Layout l, float a, String name) { + TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get()); + TextRenderer medium = MainMenuTextUtil.interMedium(bold); + HeroLayout.Sizes sz = l.sizes(); + text(bold, I18n.get("screen.lovisual.hero_menu.greeting", name.toUpperCase()), l.heroX(), l.tagY(), sz.tag(), argb(Theme.theme().accent(), a)); + text(bold, I18n.get("screen.lovisual.hero_menu.title1"), l.heroX(), l.titleY1(), sz.title(), argb(0xFFFFFF, a)); + text(bold, I18n.get("screen.lovisual.hero_menu.title2"), l.heroX(), l.titleY2(), sz.title(), argb(0xFFFFFF, a)); + text(medium, I18n.get("screen.lovisual.hero_menu.subtitle"), l.heroX(), l.subY(), sz.sub(), argb(0xFFFFFF, a * 0.65f)); + } + + public static void footer(Layout l, float a, String version) { + TextRenderer medium = MainMenuTextUtil.interMedium(MainMenuTextUtil.interBold(TextRenderer.get())); + int dim = argb(0xFFFFFF, a * 0.45f); + text(medium, I18n.get("screen.lovisual.hero_menu.disclaimer1"), l.heroX(), l.footerY(), l.sizes().footer(), dim); + text(medium, I18n.get("screen.lovisual.hero_menu.disclaimer2"), l.heroX(), l.footerY() + l.footerLine(), l.sizes().footer(), dim); + text(medium, I18n.get("screen.lovisual.hero_menu.version", version), l.heroX(), l.footerY() + l.footerLine() * 2f, l.sizes().footer(), argb(0xFFFFFF, a * 0.55f)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroPaint.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroPaint.java new file mode 100644 index 0000000..7835912 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroPaint.java @@ -0,0 +1,57 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.paint; + +import dev.loki.lovisual.features.gui.hud.config.HudRenderUtil; +import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.TextMetrics; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.model.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import net.minecraft.util.Mth; + +/** Small colour and text helpers shared by the hero menu painters. */ +public final class HeroPaint { + private HeroPaint() { + } + + public static int argb(int rgb, float alpha) { + return (rgb & 0x00FFFFFF) | (Mth.clamp(Math.round(alpha * 255f), 0, 255) << 24); + } + + public static int mix(int a, int b, float t) { + return HudRenderUtil.mixColor(a, b, t); + } + + public static float easeOutCubic(float x) { + return 1f - (float) Math.pow(1f - Mth.clamp(x, 0f, 1f), 3f); + } + + /** Draws text sized in logical pixels with the given weight renderer. */ + public static void text(TextRenderer r, String text, float x, float y, float px, int argb) { + MainMenuTextUtil.drawText(r, text, x, y, HeroLayout.font(px), argb); + } + + public static float width(TextRenderer r, String text, float px) { + return MainMenuTextUtil.measureWidth(r, text, HeroLayout.font(px)); + } + + public static float height(TextRenderer r, float px) { + return MainMenuTextUtil.measureHeight(r, HeroLayout.font(px)); + } + + /** Text vertically centred inside a box. */ + public static void textCentered(TextRenderer r, String text, float x, float y, float h, float px, int argb) { + text(r, text, x, y + (h - height(r, px)) * 0.5f, px, argb); + } + + /** Real line heights of the bold UI font, so the layout adapts to the actual glyph metrics. */ + public static TextMetrics metrics() { + TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get()); + return px -> height(bold, px); + } + + /** Draws a tinted SVG icon centred on {@code (cx, cy)}. */ + public static void icon(String svg, float cx, float cy, float size, int argb) { + Renderer2D.COLOR.svg(svg, cx - size * 0.5f, cy - size * 0.5f, size, size, SvgRenderOptions.overrideColor(argb)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroWidgetPainter.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroWidgetPainter.java new file mode 100644 index 0000000..bb770e2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/hero/paint/HeroWidgetPainter.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.paint; + +import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.DayCycle; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout; +import dev.loki.lovisual.features.gui.mainmenu.hero.logic.Rect; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import net.minecraft.client.resources.language.I18n; + +import java.time.LocalTime; +import java.util.List; + +import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.*; + +/** Top-right widget: clock, time-of-day label, theme dots and the daylight bar with its thumb. */ +public final class HeroWidgetPainter { + private static final int[] BAR_STOPS = {0x283450, 0xDC8255, 0xEBA08C, 0x373C5F}; + + private HeroWidgetPainter() { + } + + public static void draw(Layout l, float a, List dots, String currentThemeId, int hoveredDot, LocalTime now) { + float k = l.k(); + Rect w = l.widget(); + float rad = 12f * k; + Renderer2D.COLOR.roundedRectSoftShadow(w.x(), w.y() + 2f * k, w.w(), w.h(), rad, 14f * k, 0.05f, argb(0x060810, a * 0.5f)); + Renderer2D.COLOR.roundedRect(w.x(), w.y(), w.w(), w.h(), rad, argb(0x141820, a * 0.66f)); + Renderer2D.COLOR.roundedRectStrokeGradient(w.x(), w.y(), w.w(), w.h(), rad, 1f, argb(0xFFFFFF, a * 0.12f), argb(0xFFFFFF, a * 0.06f), 90f); + + TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get()); + TextRenderer medium = MainMenuTextUtil.interMedium(bold); + String time = String.format("%02d:%02d", now.getHour(), now.getMinute()); + float cx = w.x() + l.pad() + 2f * k; + float cy = w.y() + l.pad(); + text(bold, time, cx, cy, l.sizes().time(), argb(0xFFFFFF, a)); + String period = I18n.get("screen.lovisual.hero_menu.period." + DayCycle.period(now.getHour())); + float periodY = cy + height(bold, l.sizes().time()) - height(medium, 8.5f * k) - 1.5f * k; + text(medium, period, cx + width(bold, time, l.sizes().time()) + 6f * k, periodY, 8.5f * k, argb(0xFFFFFF, a * 0.55f)); + + drawDots(l, a, dots, currentThemeId, hoveredDot); + drawBar(l, a, DayCycle.progress(now.getHour(), now.getMinute())); + } + + private static void drawDots(Layout l, float a, List dots, String currentId, int hovered) { + float r = l.dotRadius(); + for (int i = 0; i < dots.size(); i++) { + Themes.ThemeEntry entry = dots.get(i); + float x = l.dotsStartX() + i * l.dotSpacing(); + int color = entry.theme().accent(); + boolean selected = entry.id().equals(currentId); + if (selected) { + Renderer2D.COLOR.circle(x, l.dotY(), r + 2.8f * l.k(), argb(color, a * 0.30f)); + } else if (i == hovered) { + Renderer2D.COLOR.circle(x, l.dotY(), r + 1.8f * l.k(), argb(0xFFFFFF, a * 0.55f)); + } + Renderer2D.COLOR.circle(x, l.dotY(), r, argb(color, a)); + } + } + + private static void drawBar(Layout l, float a, float progress) { + Rect b = l.bar(); + float rad = b.h() * 0.5f; + float seg = b.w() / 3f; + for (int i = 0; i < 3; i++) { + Renderer2D.COLOR.quad(b.x() + i * seg, b.y(), seg, b.h(), + argb(BAR_STOPS[i], a * 0.9f), argb(BAR_STOPS[i + 1], a * 0.9f), + argb(BAR_STOPS[i + 1], a * 0.9f), argb(BAR_STOPS[i], a * 0.9f)); + } + Renderer2D.COLOR.roundedRectStrokeGradient(b.x(), b.y(), b.w(), b.h(), rad, 0.8f * l.k(), + argb(0xFFFFFF, a * 0.14f), argb(0xFFFFFF, a * 0.14f), 0f); + float tx = DayCycle.thumbX(b.x(), b.w(), b.h(), progress); + float ty = b.y() + b.h() * 0.5f; + float tr = 4f * l.k(); + Renderer2D.COLOR.circle(tx, ty, tr * 1.9f, argb(0xFFFFFF, a * 0.25f)); + Renderer2D.COLOR.circle(tx, ty, tr, argb(0xFFFFFF, a)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuHoverLabel.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuHoverLabel.java new file mode 100644 index 0000000..18c4da5 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuHoverLabel.java @@ -0,0 +1,34 @@ +package dev.loki.lovisual.features.gui.mainmenu; + +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import net.minecraft.client.resources.language.I18n; + +/** Caption pill that fades in under a hovered round main-menu button, so the glyph-only row stays readable. */ +final class MainMenuHoverLabel { + private static final String[] KEYS = {"singleplayer", "multiplayer", "accounts", "addons", "options", "quit"}; + private static final float TEXT_SIZE = 0.66f; + + private MainMenuHoverLabel() { + } + + /** @param buttonX left edge of button {@code index}; @param buttonY its top edge */ + static void draw(LoVisualMainMenuScreen owner, int index, float buttonX, float buttonY, float opacity) { + float hover = owner.buttonHoverProgress[index]; + if (hover < 0.02f || opacity < 0.02f) return; + float s = LoVisualMainMenuScreen.MENU_SCALE; + TextRenderer font = MainMenuTextUtil.interMedium(MainMenuTextUtil.interBold(TextRenderer.get())); + String text = I18n.get("screen.lovisual.hero_menu." + KEYS[index]); + float size = TEXT_SIZE * s; + float w = MainMenuTextUtil.measureWidth(font, text, size); + float h = MainMenuTextUtil.measureHeight(font, size); + float padX = 9f * s; + float padY = 4f * s; + float cx = buttonX + LoVisualMainMenuScreen.BUTTON_RADIUS; + float y = buttonY + LoVisualMainMenuScreen.BUTTON_SIZE + (8f + (1f - hover) * 4f) * s; + float alpha = hover * opacity; + Renderer2D.COLOR.roundedRect(cx - w * 0.5f - padX, y, w + padX * 2f, h + padY * 2f, (h + padY * 2f) * 0.5f, + MainMenuPainter.withAlpha(0x0C0F16, Math.round(alpha * 190f))); + MainMenuTextUtil.drawText(font, text, cx - w * 0.5f, y + padY, size, MainMenuPainter.withAlpha(0xFFFFFF, Math.round(alpha * 235f))); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuRenderer.java index 04ede73..2759f3a 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuRenderer.java @@ -104,6 +104,9 @@ final class MainMenuRenderer { if (eased <= 0f) continue; renderCircleButton(i, startX + i * (LoVisualMainMenuScreen.BUTTON_SIZE + LoVisualMainMenuScreen.BUTTON_SPACING), y, eased * opacity); } + for (int i = 0; i < n; i++) { + MainMenuHoverLabel.draw(owner, i, startX + i * (LoVisualMainMenuScreen.BUTTON_SIZE + LoVisualMainMenuScreen.BUTTON_SPACING), y, opacity); + } } /** Liquid-glass circle + shadow + glyph for one button; {@code quit} tints red on hover. */ diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuTextUtil.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuTextUtil.java index 0396cc1..dfe0bbf 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuTextUtil.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/impl/MainMenuTextUtil.java @@ -12,11 +12,11 @@ import dev.loki.lovisual.render.engine.color.RenderColor; * Lives in the same package as {@link LoVisualMainMenuScreen}; kept as a separate * class so the screen file stays within the two-hundred-line cap. */ -final class MainMenuTextUtil { +public final class MainMenuTextUtil { private MainMenuTextUtil() { } - static float measureWidth(TextRenderer renderer, String text, float size) { + public static float measureWidth(TextRenderer renderer, String text, float size) { if (renderer == null || text == null) return 0f; renderer.begin(size, true, false); float width = (float) renderer.getWidth(text, false); @@ -24,7 +24,7 @@ final class MainMenuTextUtil { return width; } - static float measureHeight(TextRenderer renderer, float size) { + public static float measureHeight(TextRenderer renderer, float size) { if (renderer == null) return 0f; renderer.begin(size, true, false); float height = (float) renderer.getHeight(false); @@ -32,7 +32,7 @@ final class MainMenuTextUtil { return height; } - static void drawText(TextRenderer renderer, String text, float x, float y, float size, int argb) { + public static void drawText(TextRenderer renderer, String text, float x, float y, float size, int argb) { if (renderer == null || text == null || text.isEmpty()) return; renderer.setAlpha(1.0); renderer.begin(size, false, false); @@ -40,12 +40,12 @@ final class MainMenuTextUtil { renderer.end(); } - static void drawCenteredText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) { + public static void drawCenteredText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) { float x = centerX - measureWidth(renderer, text, size) * 0.5f; drawText(renderer, text, x, y, size, argb); } - static void drawLiquidGlassText(TextRenderer renderer, String text, float x, float y, float size, int argb) { + public static void drawLiquidGlassText(TextRenderer renderer, String text, float x, float y, float size, int argb) { if (renderer == null || text == null || text.isEmpty()) return; renderer.setAlpha(1.0); renderer.begin(size, false, true); @@ -53,13 +53,13 @@ final class MainMenuTextUtil { renderer.end(); } - static void drawCenteredLiquidGlassText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) { + public static void drawCenteredLiquidGlassText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) { float x = centerX - measureWidth(renderer, text, size) * 0.5f; drawLiquidGlassText(renderer, text, x, y, size, argb); } /** Truncate {@code text} with a "..." suffix until it fits within {@code maxWidth} logical pixels. */ - static String ellipsize(TextRenderer renderer, String text, float size, float maxWidth) { + public static String ellipsize(TextRenderer renderer, String text, float size, float maxWidth) { if (text == null || text.isEmpty()) return ""; if (measureWidth(renderer, text, size) <= maxWidth) return text; String out = text; @@ -70,11 +70,11 @@ final class MainMenuTextUtil { } /** Resolve the Inter Bold or Inter Medium {@link TextRenderer} for main-menu text, with a fallback chain. */ - static TextRenderer interBold(TextRenderer fallback) { return Fonts.renderer("Inter", FontInfo.Type.Bold, fallback); } - static TextRenderer interMedium(TextRenderer fallback) { return Fonts.renderer("InterMedium", FontInfo.Type.Regular, fallback); } - static TextRenderer iconFont(TextRenderer fallback) { return Fonts.renderer("MainMenuIcons", FontInfo.Type.Regular, fallback); } + public static TextRenderer interBold(TextRenderer fallback) { return Fonts.renderer("Inter", FontInfo.Type.Bold, fallback); } + public static TextRenderer interMedium(TextRenderer fallback) { return Fonts.renderer("InterMedium", FontInfo.Type.Regular, fallback); } + public static TextRenderer iconFont(TextRenderer fallback) { return Fonts.renderer("MainMenuIcons", FontInfo.Type.Regular, fallback); } /** Recompute the fixed logical viewport from the framebuffer size, matching {@link HudScale}. */ - static float virtualWidth(int fbw, int fbh) { return Math.max(1f, HudScale.virtualWidth(fbw, fbh)); } - static float virtualHeight(int fbw, int fbh) { return Math.max(1f, HudScale.virtualHeight(fbw, fbh)); } + public static float virtualWidth(int fbw, int fbh) { return Math.max(1f, HudScale.virtualWidth(fbw, fbh)); } + public static float virtualHeight(int fbw, int fbh) { return Math.max(1f, HudScale.virtualHeight(fbw, fbh)); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/TargetESP.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/TargetESP.java index d638ecf..4b9158f 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/TargetESP.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/TargetESP.java @@ -2,7 +2,6 @@ package dev.loki.lovisual.features.module.modules.combat.prediction; import net.minecraft.client.Minecraft; import net.minecraft.world.entity.LivingEntity; -import net.minecraft.world.entity.player.Player; import dev.loki.lovisual.config.common.CommonSettingSchemas; import dev.loki.lovisual.config.values.primitive.BooleanValue; import dev.loki.lovisual.config.values.mode.ModeValue; @@ -12,10 +11,10 @@ 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.features.module.modules.combat.targetesp.TargetEspEffects; -import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes; -import dev.loki.lovisual.features.relations.category.CategoryService; -import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.features.module.modules.combat.targetesp.constellation.TargetEspConstellation; +import dev.loki.lovisual.features.module.modules.combat.targetesp.state.TargetEspColors; +import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.TargetEspSwords; +import dev.loki.lovisual.features.module.modules.combat.targetesp.state.TargetEspTracker; import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.renderer.Renderer3DCullOptions; @@ -23,6 +22,7 @@ import dev.loki.lovisual.render.engine.renderer.Renderer3DCulling; import dev.loki.lovisual.util.target.TargetManager; import java.util.function.IntFunction; +import java.util.function.Supplier; import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects.renderBlackhole; import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects.renderGhosts; @@ -36,109 +36,92 @@ import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetE @ModuleInfo(id = "targetesp", displayName = "TargetESP", aliases = {"target", "targetrender"}, category = ModuleCategory.COMBAT) public class TargetESP extends Module { - private static final String SETTING_MODE = "mode"; - private static final String SETTING_COLOR = "color"; - private static final String SETTING_COLOR_MODE = "color_mode"; - private static final String SETTING_COLOR_SPEED = "color_speed"; - private static final String SETTING_COLOR_PHASE = "color_phase"; - private static final String SETTING_COLOR_SPREAD = "color_spread"; - private static final String SETTING_COLOR2 = "color2"; - private static final String SETTING_USE_RELATION_COLORS = "use_relation_colors"; - private static final String SETTING_RELATION_COLOR_MIX = "relation_color_mix"; - private static final String SETTING_CROSSHAIR_TARGETS = "crosshair_targets"; - private static final String SETTING_ALPHA_ANIMATION_SPEED = "alpha_animation_speed"; - private static final String SETTING_ESP_LENGTH = "esp_length"; - private static final String SETTING_ESP_FACTOR = "esp_factor"; - private static final String SETTING_ESP_SHAKING = "esp_shaking"; - private static final String SETTING_ESP_AMPLITUDE = "esp_amplitude"; - private static final String SETTING_CAPTURE_SPIN_SPEED = "capture_spin_speed"; - private static final String SETTING_CRYSTAL_HIT_RED = "crystal_hit_red"; - private static final String SETTING_BLACKHOLE_SIZE = "blackhole_size"; - private static final String SETTING_BLACKHOLE_SPIN_SPEED = "blackhole_spin_speed"; - private static final String SETTING_BLACKHOLE_STYLE = "blackhole_style"; - private static final String SETTING_VORTEX_SPEED = "vortex_speed"; - private static final float ALPHA_TICK_DT = 1.0f / 20.0f; - private static final float ALPHA_SNAP_EPSILON = 0.02f; - private final ModeValue espMode = modeSetting( - "targetEspMode", - SETTING_MODE, - "ghosts", - "ghosts", "ring", "capture_mark", "crystals", "blackhole", "vortex" - ); - private final RGBAColorValue colorValue = color("targetEspColor", SETTING_COLOR, "#FFFF5555"); - private final ModeValue colorMode = visibleWhen(modeSetting( - "targetEspColorMode", - SETTING_COLOR_MODE, - "Static", - "Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme" - ), () -> true); + private final ModeValue espMode = modeSetting("targetEspMode", "mode", "ghosts", + "ghosts", "ring", "capture_mark", "crystals", "blackhole", "vortex", "constellation", "swords"); + private final RGBAColorValue colorValue = color("targetEspColor", "color", "#FFFF5555"); + private final ModeValue colorMode = visibleWhen(modeSetting("targetEspColorMode", "color_mode", "Static", + "Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"), () -> true); private final NumberValue colorSpeed = - visibleWhen(num("targetEspColorSpeed", SETTING_COLOR_SPEED, 18, 2, 54), () -> true); - private final NumberValue colorPhase = - num("targetEspColorPhase", SETTING_COLOR_PHASE, 0, 0, 360); - private final NumberValue colorSpread = - num("targetEspColorSpread", SETTING_COLOR_SPREAD, 90, -720, 720); + visibleWhen(num("targetEspColorSpeed", "color_speed", 18, 2, 54), () -> true); + private final NumberValue colorPhase = num("targetEspColorPhase", "color_phase", 0, 0, 360); + private final NumberValue colorSpread = num("targetEspColorSpread", "color_spread", 90, -720, 720); private final RGBAColorValue colorValue2 = - visibleWhen(color("targetEspColor2", SETTING_COLOR2, "#FF55FFFF"), this::usesSecondaryColor); - private final BooleanValue useRelationColors = - boolCommon("targetEspUseRelationColors", SETTING_USE_RELATION_COLORS, CommonSettingSchemas.PLAYER_USE_RELATIONS, false); - private final NumberValue relationColorMix = - visibleWhen(num("targetEspRelationColorMix", SETTING_RELATION_COLOR_MIX, 1.0f, 0.0f, 1.0f), useRelationColors::get); - private final BooleanValue crosshairTargets = - bool("targetEspCrosshairTargets", SETTING_CROSSHAIR_TARGETS, true); + visibleWhen(color("targetEspColor2", "color2", "#FF55FFFF"), this::usesSecondaryColor); + private final BooleanValue useRelationColors = boolCommon("targetEspUseRelationColors", "use_relation_colors", + CommonSettingSchemas.PLAYER_USE_RELATIONS, false); + private final NumberValue relationColorMix = visibleWhen( + num("targetEspRelationColorMix", "relation_color_mix", 1.0f, 0.0f, 1.0f), useRelationColors::get); + private final BooleanValue crosshairTargets = bool("targetEspCrosshairTargets", "crosshair_targets", true); private final NumberValue alphaAnimationSpeed = - num("targetEspAlphaAnimationSpeed", SETTING_ALPHA_ANIMATION_SPEED, 8.0f, 2.0f, 10.0f); + num("targetEspAlphaAnimationSpeed", "alpha_animation_speed", 8.0f, 2.0f, 10.0f); private final NumberValue espLength = - visibleWhen(num("targetEspLength", SETTING_ESP_LENGTH, 14, 1, 40), this::isGhostMode); + visibleWhen(num("targetEspLength", "esp_length", 14, 1, 40), onMode("ghosts")); private final NumberValue espFactor = - visibleWhen(num("targetEspFactor", SETTING_ESP_FACTOR, 8, 1, 20), this::isGhostMode); + visibleWhen(num("targetEspFactor", "esp_factor", 8, 1, 20), onMode("ghosts")); private final NumberValue espShaking = - visibleWhen(num("targetEspShaking", SETTING_ESP_SHAKING, 1.8f, 1.5f, 10f), this::isGhostMode); + visibleWhen(num("targetEspShaking", "esp_shaking", 1.8f, 1.5f, 10f), onMode("ghosts")); private final NumberValue espAmplitude = - visibleWhen(num("targetEspAmplitude", SETTING_ESP_AMPLITUDE, 3f, 0.1f, 8f), this::isGhostMode); - private final NumberValue captureSpinSpeed = - visibleWhen(num("targetEspCaptureSpinSpeed", SETTING_CAPTURE_SPIN_SPEED, 1.0f, 0.2f, 4.0f), this::isCaptureMarkMode); + visibleWhen(num("targetEspAmplitude", "esp_amplitude", 3f, 0.1f, 8f), onMode("ghosts")); + private final NumberValue captureSpinSpeed = visibleWhen( + num("targetEspCaptureSpinSpeed", "capture_spin_speed", 1.0f, 0.2f, 4.0f), onMode("capture_mark")); private final BooleanValue crystalHitRed = - visibleWhen(bool("targetEspCrystalHitRed", SETTING_CRYSTAL_HIT_RED, true), this::isCrystalsMode); - private final NumberValue blackholeSize = - visibleWhen(num("targetEspBlackholeSize", SETTING_BLACKHOLE_SIZE, 1.1f, 0.5f, 3.0f), this::isBlackholeMode); - private final NumberValue blackholeSpinSpeed = - visibleWhen(num("targetEspBlackholeSpinSpeed", SETTING_BLACKHOLE_SPIN_SPEED, 1.0f, 0.2f, 4.0f), this::isBlackholeMode); - private final ModeValue blackholeStyle = - visibleWhen(modeSetting("targetEspBlackholeStyle", SETTING_BLACKHOLE_STYLE, "Single", "Single", "Body"), this::isBlackholeMode); + visibleWhen(bool("targetEspCrystalHitRed", "crystal_hit_red", true), onMode("crystals")); + private final NumberValue blackholeSize = visibleWhen( + num("targetEspBlackholeSize", "blackhole_size", 1.1f, 0.5f, 3.0f), onMode("blackhole")); + private final NumberValue blackholeSpinSpeed = visibleWhen( + num("targetEspBlackholeSpinSpeed", "blackhole_spin_speed", 1.0f, 0.2f, 4.0f), onMode("blackhole")); + private final ModeValue blackholeStyle = visibleWhen( + modeSetting("targetEspBlackholeStyle", "blackhole_style", "Single", "Single", "Body"), onMode("blackhole")); private final NumberValue vortexSpeed = - visibleWhen(num("targetEspVortexSpeed", SETTING_VORTEX_SPEED, 1.2f, 0.2f, 4.0f), this::isVortexMode); + visibleWhen(num("targetEspVortexSpeed", "vortex_speed", 1.2f, 0.2f, 4.0f), onMode("vortex")); + private final NumberValue constellationStars = visibleWhen( + num("targetEspConstellationStars", "constellation_stars", 9, 3, 24), onMode("constellation")); + private final NumberValue constellationSpeed = visibleWhen( + num("targetEspConstellationSpeed", "constellation_speed", 1.0f, 0.2f, 4.0f), onMode("constellation")); + private final NumberValue constellationOrbit = visibleWhen( + num("targetEspConstellationOrbit", "constellation_orbit", 1.1f, 0.5f, 2.5f), onMode("constellation")); + private final BooleanValue constellationLines = visibleWhen( + bool("targetEspConstellationLines", "constellation_lines", true), onMode("constellation")); + private final NumberValue constellationPointSize = visibleWhen( + num("targetEspConstellationPointSize", "constellation_point_size", 0.12f, 0.05f, 0.4f), onMode("constellation")); + private final NumberValue constellationGlow = visibleWhen( + num("targetEspConstellationGlow", "constellation_glow", 1.0f, 0.0f, 2.0f), onMode("constellation")); + private final NumberValue swordsSpeed = visibleWhen( + num("targetEspSwordsSpeed", "swords_speed", 2200, 600, 8000), onMode("swords")); + private final NumberValue swordsRadius = visibleWhen( + num("targetEspSwordsRadius", "swords_radius", 1.0f, 0.5f, 2.5f), onMode("swords")); + private final NumberValue swordsSize = visibleWhen( + num("targetEspSwordsSize", "swords_size", 1.0f, 0.3f, 2.5f), onMode("swords")); + private final NumberValue swordsBob = visibleWhen( + num("targetEspSwordsBob", "swords_bob", 0.06f, 0.0f, 0.4f), onMode("swords")); + private final BooleanValue swordsGlow = visibleWhen(bool("targetEspSwordsGlow", "swords_glow", true), onMode("swords")); + private final BooleanValue swordsPhysics = + visibleWhen(bool("targetEspSwordsPhysics", "swords_physics", true), onMode("swords")); + private final TargetEspColors colors = new TargetEspColors(colorMode, colorSpeed, colorPhase, colorSpread, + colorValue, colorValue2, useRelationColors, relationColorMix); + private final TargetEspTracker tracker = new TargetEspTracker(); private final TargetEspCrystalRenderer crystalRenderer = new TargetEspCrystalRenderer(); - private LivingEntity target; - private LivingEntity renderTarget; - private float targetAlpha; + private final TargetEspSwords swords = new TargetEspSwords(); @Override public void onDisable() { - target = null; - renderTarget = null; - targetAlpha = 0.0f; + tracker.reset(); crystalRenderer.clear(); + swords.reset(); } @Override public void onTick() { Minecraft mc = Minecraft.getInstance(); - if (!isEnabled() || mc.level == null) { - target = null; - updateRenderTarget(null); - crystalRenderer.clear(); - return; - } - target = TargetManager.getTarget(crosshairTargets.get()); - updateRenderTarget(target); - if (renderTarget == null) { + LivingEntity target = isEnabled() && mc.level != null ? TargetManager.getTarget(crosshairTargets.get()) : null; + if (tracker.update(target, alphaAnimationSpeed.get()) || tracker.renderTarget() == null) { crystalRenderer.clear(); + swords.reset(); } + if (!isEnabled() || mc.level == null) return; - setCaptureSpeedScale(isCaptureMarkMode() ? captureSpinSpeed.get() : 1.0f); - - if (renderTarget != null && targetAlpha > 0.0f) { + setCaptureSpeedScale(espMode.get().equals("capture_mark") ? captureSpinSpeed.get() : 1.0f); + if (tracker.isVisible()) { tickTargetEsp(); } } @@ -150,153 +133,46 @@ public class TargetESP extends Module { @Override public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { - if (!isEnabled() || renderTarget == null || targetAlpha <= 0.001f) return; - Renderer3DCullOptions cull = resolveCullOptions(renderTarget); - if (!Renderer3DCulling.shouldRender(renderTarget, tickDelta, cull)) return; + LivingEntity target = tracker.renderTarget(); + if (!isEnabled() || !tracker.isVisible()) return; + if (!Renderer3DCulling.shouldRender(target, tickDelta, resolveCullOptions(target))) return; - IntFunction palette = index -> AnimatedRenderColors.scaleAlpha(getColorArgb(index), targetAlpha); - - String mode = espMode.get(); - if ("ring".equals(mode)) { - drawOldTargetEsp(renderer, renderTarget, tickDelta, palette); - } else if ("ghosts".equals(mode)) { - renderGhosts( - renderer, - espLength.get(), - espFactor.get(), - espShaking.get(), - espAmplitude.get(), - renderTarget, - tickDelta, - palette - ); - } else if ("capture_mark".equals(mode)) { - renderCaptureMark(renderer, renderTarget, tickDelta, palette); - } else if ("crystals".equals(mode)) { - crystalRenderer.render(renderer, renderTarget, tickDelta, crystalHitRed.get(), targetAlpha, this::getColorArgb); - } else if ("blackhole".equals(mode)) { - renderBlackhole(renderer, renderTarget, tickDelta, palette, - blackholeSize.get(), blackholeSpinSpeed.get(), targetAlpha, blackholeStyle.get()); - } else if ("vortex".equals(mode)) { - renderVortex(renderer, renderTarget, tickDelta, palette, vortexSpeed.get()); - } - } - - private void updateRenderTarget(LivingEntity next) { - float dt = Math.max(AnimationUtility.deltaTime(), ALPHA_TICK_DT); - float speed = alphaAnimationSpeed.get(); - if (next != null) { - if (renderTarget == null) { - renderTarget = next; - targetAlpha = AnimationUtility.snap( - AnimationUtility.approach(targetAlpha, 1.0f, dt, speed), - 1.0f, - ALPHA_SNAP_EPSILON - ); - return; - } - if (renderTarget != next) { - renderTarget = next; - targetAlpha = 1.0f; - crystalRenderer.clear(); - return; - } - targetAlpha = AnimationUtility.snap( - AnimationUtility.approach(targetAlpha, 1.0f, dt, speed), - 1.0f, - ALPHA_SNAP_EPSILON - ); - return; - } - - targetAlpha = AnimationUtility.snap( - AnimationUtility.approach(targetAlpha, 0.0f, dt, speed), - 0.0f, - ALPHA_SNAP_EPSILON - ); - if (targetAlpha <= 0.0f) { - renderTarget = null; - crystalRenderer.clear(); + float alpha = tracker.alpha(); + IntFunction palette = index -> AnimatedRenderColors.scaleAlpha(getColorArgb(index), alpha); + switch (espMode.get()) { + case "ring" -> drawOldTargetEsp(renderer, target, tickDelta, palette); + case "ghosts" -> renderGhosts(renderer, espLength.get(), espFactor.get(), espShaking.get(), + espAmplitude.get(), target, tickDelta, palette); + case "capture_mark" -> renderCaptureMark(renderer, target, tickDelta, palette); + case "crystals" -> crystalRenderer.render(renderer, target, tickDelta, crystalHitRed.get(), alpha, this::getColorArgb); + case "blackhole" -> renderBlackhole(renderer, target, tickDelta, palette, + blackholeSize.get(), blackholeSpinSpeed.get(), alpha, blackholeStyle.get()); + case "vortex" -> renderVortex(renderer, target, tickDelta, palette, vortexSpeed.get()); + case "constellation" -> TargetEspConstellation.render(renderer, target, tickDelta, palette, + constellationStars.get(), constellationSpeed.get(), constellationOrbit.get(), + constellationLines.get(), constellationPointSize.get(), constellationGlow.get()); + case "swords" -> swords.render(renderer, target, tickDelta, palette, swordsSpeed.get(), swordsRadius.get(), + swordsSize.get(), swordsBob.get(), swordsGlow.get(), swordsPhysics.get()); + default -> { } } } + // Raycast-selected targets may be behind walls: fall back to a look-based frustum check. private Renderer3DCullOptions resolveCullOptions(LivingEntity target) { Minecraft mc = Minecraft.getInstance(); - if (target == null || mc.player == null) { - return Renderer3DCullOptions.SMART_TARGET; - } - // Allow rendering through walls if the current target is already selected, - // so raycast-based targeting doesn't get culled by line-of-sight checks. - if (!mc.player.hasLineOfSight(target)) { - return Renderer3DCullOptions.FRUSTUM_LOOK; - } - return Renderer3DCullOptions.SMART_TARGET; + boolean visible = mc.player == null || mc.player.hasLineOfSight(target); + return visible ? Renderer3DCullOptions.SMART_TARGET : Renderer3DCullOptions.FRUSTUM_LOOK; } - private boolean isGhostMode() { - return "ghosts".equals(espMode.get()); - } - - private boolean isCaptureMarkMode() { - return "capture_mark".equals(espMode.get()); - } - - private boolean isCrystalsMode() { - return "crystals".equals(espMode.get()); - } - - private boolean isBlackholeMode() { - return "blackhole".equals(espMode.get()); - } - - private boolean isVortexMode() { - return "vortex".equals(espMode.get()); + private Supplier onMode(String mode) { + return () -> mode.equals(espMode.get()); } private boolean usesSecondaryColor() { - return AnimatedRenderColors.usesSecondary(animatedColorMode()); + return colors.usesSecondary(); } private int getColorArgb(int count) { - int phase = colorPhase.get() + Math.round(count * colorSpread.get() / 90.0f); - int animated = AnimatedRenderColors.resolve( - animatedColorMode(), - colorSpeed.get(), - phase, - colorValue.getArgb(), - colorValue2.getArgb(), - true - ); - if (!useRelationColors.get() || renderTarget == null) { - return animated; - } - - int relation = relationColor(renderTarget, animated); - relation = AnimatedRenderColors.withAlpha(relation, (animated >>> 24) & 0xFF); - return AnimatedRenderColors.mixArgb(animated, relation, relationColorMix.get()); - } - - private int relationColor(LivingEntity entity, int fallback) { - if (entity instanceof Player player) { - return CategoryService.getColor(player); - } - String name = entity.getName() != null ? entity.getName().getString() : null; - if (name == null || name.isBlank()) { - return fallback; - } - return CategoryService.getColor(name); - } - - private AnimatedRenderColors.Mode animatedColorMode() { - return switch (colorMode.get()) { - case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW; - case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW; - case "Sky" -> AnimatedRenderColors.Mode.SKY; - case "Fade" -> AnimatedRenderColors.Mode.FADE; - case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR; - case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS; - case "Theme" -> AnimatedRenderColors.Mode.THEME; - default -> AnimatedRenderColors.Mode.STATIC; - }; + return colors.argb(count, tracker.renderTarget()); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayout.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayout.java new file mode 100644 index 0000000..44873b2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayout.java @@ -0,0 +1,42 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.constellation; + +/** + * Pure geometry of the "constellation" TargetESP mode: stars on a tilted, + * slightly wobbling orbit around the target centre. Offsets are relative to + * the target's feet position. + */ +public final class ConstellationLayout { + private static final double TAU = Math.PI * 2.0; + + private ConstellationLayout() { + } + + /** + * @param out 3 * stars slots, filled with x, y, z per star + * @param orbit orbit radius in target widths + * @param time seconds + * @param speed spin multiplier + */ + public static void positions(double[] out, int stars, float width, float height, + float orbit, double time, float speed) { + double spin = time * speed * 1.5; + for (int s = 0; s < stars; s++) { + double frac = (double) s / stars; + double base = frac * TAU; + double incl = Math.PI * 0.35 * Math.sin(frac * Math.PI * 1.5 + 0.5); + double ang = spin + base; + double r = orbit * width * (0.85 + 0.15 * Math.sin(base * 2.3)); + out[s * 3] = Math.cos(ang) * r; + out[s * 3 + 1] = height / 2.0 + + Math.sin(ang) * Math.sin(incl) * r * 0.7 + + Math.sin(time * 1.2 + frac * TAU) * 0.2; + out[s * 3 + 2] = Math.sin(ang) * Math.cos(incl) * r; + } + } + + /** Star brightness pulse in [0.5, 1.0]. */ + public static float pulse(double time, int star, int stars) { + double frac = (double) star / stars; + return (float) (0.75 + 0.25 * Math.sin(time * 3.0 + frac * TAU * 1.7)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/TargetEspConstellation.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/TargetEspConstellation.java new file mode 100644 index 0000000..f845316 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/TargetEspConstellation.java @@ -0,0 +1,88 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.constellation; + +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.math.RenderMath; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.texture.TextureStorage; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.phys.Vec3; + +import java.util.function.IntFunction; + +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*; + +/** "Constellation" TargetESP mode: glowing stars orbiting the target, joined by thin lines. */ +public final class TargetEspConstellation { + private static final float LINE_ALPHA = 120.0f / 255.0f; + private static final float GLOW_SCALE = 3.5f; + private static final float GLOW_ALPHA = 0.3f; + + private TargetEspConstellation() { + } + + public static void render(Renderer3D renderer, LivingEntity target, float tickDelta, + IntFunction palette, int stars, float speed, float orbit, + boolean lines, float pointSize, float glow) { + if (renderer == null || target == null || stars < 2) return; + + Vec3 pos = RenderMath.getLerpedPos(target, tickDelta); + double time = (GameClock.millis() % 100000L) / 1000.0; + double[] p = new double[stars * 3]; + ConstellationLayout.positions(p, stars, target.getBbWidth(), target.getBbHeight(), orbit, time, speed); + + if (lines) { + drawLines(renderer, pos, p, stars, palette); + } + drawStars(renderer, pos, p, stars, time, palette, pointSize, glow); + } + + private static void drawLines(Renderer3D renderer, Vec3 pos, double[] p, int stars, + IntFunction palette) { + float prevWidth = RenderState.lineWidth; + RenderState.lineWidth = 1.2f; + MeshBuilder mesh; + try { + mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE); + } finally { + RenderState.lineWidth = prevWidth; + } + if (mesh == null) return; + + for (int s = 0; s < stars; s++) { + int n = (s + 1) % stars; + int color = applyOpacity(palette.apply((int) (360.0 * s / stars)), LINE_ALPHA); + mesh.ensureLineCapacity(); + int a = addVertex(mesh, pos.x + p[s * 3], pos.y + p[s * 3 + 1], pos.z + p[s * 3 + 2], color); + int b = addVertex(mesh, pos.x + p[n * 3], pos.y + p[n * 3 + 1], pos.z + p[n * 3 + 2], color); + mesh.line(a, b); + } + } + + private static void drawStars(Renderer3D renderer, Vec3 pos, double[] p, int stars, double time, + IntFunction palette, float pointSize, float glow) { + MeshBuilder core = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, + TextureStorage.PARTICLE_STARS, Renderer3D.DepthMode.MAIN); + MeshBuilder halo = glow > 0.01f + ? renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, + TextureStorage.BLOOM, Renderer3D.DepthMode.MAIN) + : null; + if (core == null) return; + + for (int s = 0; s < stars; s++) { + float pulse = ConstellationLayout.pulse(time, s, stars); + float alpha = 0.7f + 0.3f * pulse; + int color = palette.apply((int) (360.0 * s / stars)); + double x = pos.x + p[s * 3]; + double y = pos.y + p[s * 3 + 1]; + double z = pos.z + p[s * 3 + 2]; + float size = pointSize * pulse; + addBillboardQuad(core, x, y, z, size, applyOpacity(color, alpha)); + if (halo != null) { + addBillboardQuad(halo, x, y, z, size * GLOW_SCALE, applyOpacity(color, alpha * glow * GLOW_ALPHA)); + } + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspColors.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspColors.java new file mode 100644 index 0000000..00b2d6f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspColors.java @@ -0,0 +1,75 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.state; + +import dev.loki.lovisual.config.values.color.RGBAColorValue; +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.relations.category.CategoryService; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; + +/** Resolves TargetESP colors from the module's color settings, optionally mixed with relation colors. */ +public final class TargetEspColors { + private final ModeValue mode; + private final NumberValue speed; + private final NumberValue phase; + private final NumberValue spread; + private final RGBAColorValue primary; + private final RGBAColorValue secondary; + private final BooleanValue useRelations; + private final NumberValue relationMix; + + public TargetEspColors(ModeValue mode, NumberValue speed, NumberValue phase, + NumberValue spread, RGBAColorValue primary, RGBAColorValue secondary, + BooleanValue useRelations, NumberValue relationMix) { + this.mode = mode; + this.speed = speed; + this.phase = phase; + this.spread = spread; + this.primary = primary; + this.secondary = secondary; + this.useRelations = useRelations; + this.relationMix = relationMix; + } + + public boolean usesSecondary() { + return AnimatedRenderColors.usesSecondary(animatedMode()); + } + + /** @param target entity whose relation color is mixed in, or null to skip relations */ + public int argb(int count, LivingEntity target) { + int shifted = phase.get() + Math.round(count * spread.get() / 90.0f); + int animated = AnimatedRenderColors.resolve( + animatedMode(), speed.get(), shifted, primary.getArgb(), secondary.getArgb(), true); + if (!useRelations.get() || target == null) { + return animated; + } + int relation = AnimatedRenderColors.withAlpha(relationColor(target, animated), (animated >>> 24) & 0xFF); + return AnimatedRenderColors.mixArgb(animated, relation, relationMix.get()); + } + + private int relationColor(LivingEntity entity, int fallback) { + if (entity instanceof Player player) { + return CategoryService.getColor(player); + } + String name = entity.getName() != null ? entity.getName().getString() : null; + if (name == null || name.isBlank()) { + return fallback; + } + return CategoryService.getColor(name); + } + + private AnimatedRenderColors.Mode animatedMode() { + return switch (mode.get()) { + case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW; + case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW; + case "Sky" -> AnimatedRenderColors.Mode.SKY; + case "Fade" -> AnimatedRenderColors.Mode.FADE; + case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR; + case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS; + case "Theme" -> AnimatedRenderColors.Mode.THEME; + default -> AnimatedRenderColors.Mode.STATIC; + }; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspTracker.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspTracker.java new file mode 100644 index 0000000..be7049e --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/state/TargetEspTracker.java @@ -0,0 +1,53 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.state; + +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import net.minecraft.world.entity.LivingEntity; + +/** Follows the current TargetESP target and fades its alpha in and out. */ +public final class TargetEspTracker { + private static final float TICK_DT = 1.0f / 20.0f; + private static final float SNAP_EPSILON = 0.02f; + + private LivingEntity renderTarget; + private float alpha; + + public LivingEntity renderTarget() { + return renderTarget; + } + + public float alpha() { + return alpha; + } + + public boolean isVisible() { + return renderTarget != null && alpha > 0.001f; + } + + public void reset() { + renderTarget = null; + alpha = 0.0f; + } + + /** + * @return true when the render target switched or vanished, so per-target + * caches must be dropped + */ + public boolean update(LivingEntity next, float animationSpeed) { + float dt = Math.max(AnimationUtility.deltaTime(), TICK_DT); + if (next == null) { + alpha = approach(0.0f, dt, animationSpeed); + if (alpha > 0.0f) return false; + boolean had = renderTarget != null; + renderTarget = null; + return had; + } + boolean switched = renderTarget != null && renderTarget != next; + renderTarget = next; + alpha = switched ? 1.0f : approach(1.0f, dt, animationSpeed); + return switched; + } + + private float approach(float to, float dt, float speed) { + return AnimationUtility.snap(AnimationUtility.approach(alpha, to, dt, speed), to, SNAP_EPSILON); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysics.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysics.java new file mode 100644 index 0000000..aad0345 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysics.java @@ -0,0 +1,101 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.sword; + +import java.util.Random; + +/** + * Spring physics of the "swords" TargetESP mode: every sword is pulled towards its slot on an + * orbit around the target, gets kicked outwards when the target is hurt, and leans into its + * motion. Pure math, no Minecraft types. + */ +final class SwordPhysics { + static final int COUNT = 8; + private static final double SPRING = 0.22; + private static final double DAMPING = 0.78; + + private SwordPhysics() { + } + + static final class Sword { + double x, y, z; + double vx, vy, vz; + float roll; + float pitch; + } + + /** Frame inputs; angles in radians, {@code step} = frame time in 1/60 s units clamped to [0.2, 2]. */ + record Frame(double targetX, double targetZ, double centerY, double radius, double orbitAngle, + double timeMs, double bobAmplitude, float step, boolean kick, boolean physics, + double cameraYawRad) { + } + + /** Places every sword on its orbit slot at rest. */ + static void place(Sword[] swords, double targetX, double targetZ, double centerY, double radius, double orbitAngle) { + for (int i = 0; i < swords.length; i++) { + Sword s = swords[i]; + double a = orbitAngle + i * (Math.PI * 2.0 / swords.length); + s.x = targetX + Math.cos(a) * radius; + s.y = centerY; + s.z = targetZ + Math.sin(a) * radius; + s.vx = 0; + s.vy = 0; + s.vz = 0; + s.roll = 0; + s.pitch = 0; + } + } + + static void step(Sword[] swords, Frame f, Random rnd) { + for (int i = 0; i < swords.length; i++) { + Sword s = swords[i]; + double a = f.orbitAngle() + i * (Math.PI * 2.0 / swords.length); + double bob = Math.sin(f.timeMs() * 0.0035 + i * (Math.PI / 4.0)) * f.bobAmplitude(); + double idealX = f.targetX() + Math.cos(a) * f.radius(); + double idealY = f.centerY() + bob; + double idealZ = f.targetZ() + Math.sin(a) * f.radius(); + if (f.kick()) kick(s, f, rnd); + if (!f.physics()) { + s.x = idealX; + s.y = idealY; + s.z = idealZ; + s.vx = 0; + s.vy = 0; + s.vz = 0; + s.roll = 0; + s.pitch = 0; + continue; + } + double spring = SPRING * f.step(); + double damping = Math.pow(DAMPING, f.step()); + s.vx = (s.vx + (idealX - s.x) * spring) * damping; + s.vy = (s.vy + (idealY - s.y) * spring) * damping; + s.vz = (s.vz + (idealZ - s.z) * spring) * damping; + s.x += s.vx * f.step(); + s.y += s.vy * f.step(); + s.z += s.vz * f.step(); + lean(s, f); + } + } + + private static void kick(Sword s, Frame f, Random rnd) { + double dx = s.x - f.targetX(); + double dz = s.z - f.targetZ(); + double len = Math.hypot(dx, dz); + if (len > 0.001) { + double power = 0.16 + rnd.nextDouble() * 0.06; + s.vx += dx / len * power; + s.vz += dz / len * power; + s.vy += 0.07; + } + s.roll += (float) ((rnd.nextDouble() - 0.5) * 28.0); + } + + /** Roll follows sideways speed as seen from the camera, pitch follows vertical speed. */ + private static void lean(Sword s, Frame f) { + double sideSpeed = -s.vx * Math.cos(f.cameraYawRad()) - s.vz * Math.sin(f.cameraYawRad()); + float targetRoll = (float) Math.max(-42.0, Math.min(42.0, sideSpeed * 130.0)); + float targetPitch = (float) Math.max(-32.0, Math.min(32.0, -s.vy * 85.0)); + float k = Math.min(1f, 0.25f * f.step()); + s.roll += (targetRoll - s.roll) * k; + s.pitch += (targetPitch - s.pitch) * k; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/TargetEspSwords.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/TargetEspSwords.java new file mode 100644 index 0000000..ad7341f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/TargetEspSwords.java @@ -0,0 +1,107 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.sword; + +import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.SwordPhysics.Frame; +import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.SwordPhysics.Sword; +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; +import dev.loki.lovisual.render.engine.math.RenderMath; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.texture.TextureStorage; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.Random; +import java.util.function.IntFunction; + +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addBillboardVertices; +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.applyOpacity; + +/** "Swords" TargetESP mode: eight glowing swords orbit the target and recoil when it takes a hit. */ +public final class TargetEspSwords { + private static final int HURT_COLOR = 0xFFFF4178; + private static final float GLOW_SCALE = 1.35f; + private static final float GLOW_ALPHA = 0.45f; + + private final Sword[] swords = new Sword[SwordPhysics.COUNT]; + private final Random random = new Random(); + private LivingEntity lastTarget; + private int lastHurt; + private long lastMs; + + public TargetEspSwords() { + for (int i = 0; i < swords.length; i++) { + swords[i] = new Sword(); + } + } + + public void reset() { + lastTarget = null; + lastHurt = 0; + lastMs = 0L; + } + + /** + * @param speedMs milliseconds per orbit + * @param radius orbit radius multiplier + * @param size sword size multiplier + * @param bob vertical bobbing amplitude in blocks + */ + public void render(Renderer3D renderer, LivingEntity target, float tickDelta, IntFunction palette, + long speedMs, float radius, float size, float bob, boolean glow, boolean physics) { + if (renderer == null || target == null) return; + long now = GameClock.millis(); + float dt = lastMs == 0L ? 0f : Math.min(0.1f, (now - lastMs) / 1000f); + lastMs = now; + float step = Math.max(0.2f, Math.min(2.0f, dt * 60f)); + + Vec3 pos = RenderMath.getLerpedPos(target, tickDelta); + double centerY = pos.y + target.getBbHeight() * 0.45; + double orbitRadius = (target.getBbWidth() + 0.35) * radius; + long period = Math.max(200L, speedMs); + double orbit = (double) (now % period) / period * Math.PI * 2.0; + + if (lastTarget != target) { + lastTarget = target; + lastHurt = target.hurtTime; + SwordPhysics.place(swords, pos.x, pos.z, centerY, orbitRadius, orbit); + } + int hurt = target.hurtTime; + boolean kick = hurt > lastHurt && hurt >= 9; + lastHurt = hurt; + float hurtPc = hurt > 0 ? (float) Math.sin(hurt * (Math.PI / 10.0)) : 0f; + + float yawRad = (float) Math.toRadians(net.minecraft.client.Minecraft.getInstance().gameRenderer.mainCamera().yRot()); + SwordPhysics.step(swords, new Frame(pos.x, pos.z, centerY, orbitRadius, orbit, now, bob, step, kick, + physics, yawRad), random); + + float halfW = 0.20f * size; + float halfH = 0.40f * size; + if (glow) { + MeshBuilder halo = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, + TextureStorage.BLOOM, Renderer3D.DepthMode.NONE); + if (halo != null) draw(halo, palette, hurtPc, halfW * GLOW_SCALE, halfH * GLOW_SCALE, GLOW_ALPHA * (1f + hurtPc * 0.65f)); + } + MeshBuilder core = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, TextureStorage.SWORD, + Renderer3D.DepthMode.NONE); + if (core != null) draw(core, palette, hurtPc, halfW, halfH, 1f); + } + + private void draw(MeshBuilder mesh, IntFunction palette, float hurtPc, float halfW, float halfH, float alpha) { + for (int i = 0; i < swords.length; i++) { + Sword s = swords[i]; + int base = AnimatedRenderColors.mixArgb(palette.apply(i * 45), HURT_COLOR, hurtPc); + int color = applyOpacity(base, alpha); + Quaternionf rot = new Quaternionf(RenderState.cameraRotation) + .rotateZ((float) Math.toRadians(s.roll)) + .rotateX((float) Math.toRadians(s.pitch)); + Vector3f right = new Vector3f(1, 0, 0).rotate(rot).mul(halfW); + Vector3f up = new Vector3f(0, 1, 0).rotate(rot).mul(halfH); + addBillboardVertices(mesh, s.x, s.y, s.z, right, up, color, color, color, color); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceChat.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceChat.java new file mode 100644 index 0000000..2a8490a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceChat.java @@ -0,0 +1,100 @@ +package dev.loki.lovisual.features.module.modules.misc.voice; + +import dev.loki.lovisual.compat.voicechat.VoiceChatBridge; +import dev.loki.lovisual.compat.voicechat.VoiceState; +import dev.loki.lovisual.config.values.color.RGBAColorValue; +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.math.RenderMath; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.texture.TextureStorage; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; + +/** Simple Voice Chat companion: pulsing indicators above players who are talking. */ +@ModuleInfo(id = "voicechat", displayName = "VoiceChat", category = ModuleCategory.MISC) +public class VoiceChat extends Module { + private static final float HEAD_OFFSET = 0.55f; + private static final int MAX_SPEAKERS = 64; + + private final NumberValue holdMs = num("voiceChatHoldMs", "hold_ms", 350, 100, 1500); + private final NumberValue maxDistance = num("voiceChatMaxDistance", "max_distance", 32.0f, 8.0f, 96.0f); + private final NumberValue indicatorSize = num("voiceChatIndicatorSize", "indicator_size", 1.0f, 0.5f, 2.5f); + private final BooleanValue pulse = bool("voiceChatPulse", "pulse", true); + private final BooleanValue showSelf = bool("voiceChatShowSelf", "show_self", true); + private final BooleanValue showMuted = visibleWhen(bool("voiceChatShowMuted", "show_muted", true), showSelf::get); + private final RGBAColorValue color = color("voiceChatColor", "color", "#FF55FF77"); + private final RGBAColorValue whisperColor = color("voiceChatWhisperColor", "whisper_color", "#FFFFD255"); + private final RGBAColorValue selfColor = + visibleWhen(color("voiceChatSelfColor", "self_color", "#FF55AAFF"), showSelf::get); + private final RGBAColorValue mutedColor = visibleWhen( + color("voiceChatMutedColor", "muted_color", "#FFFF5555"), () -> showSelf.get() && showMuted.get()); + + @Override + protected String getUnavailableReason() { + return VoiceChatBridge.isInstalled() ? "" : "Simple Voice Chat is not installed"; + } + + @Override + public WorldPhase getWorldPhase() { + return WorldPhase.AFTER_POST_PROCESS; + } + + @Override + public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { + Minecraft mc = Minecraft.getInstance(); + if (!isEnabled() || mc.level == null || mc.player == null || !VoiceChatBridge.isInstalled()) return; + + MeshBuilder rings = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, Renderer3D.DepthMode.MAIN); + MeshBuilder halo = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, + TextureStorage.BLOOM, Renderer3D.DepthMode.MAIN); + if (rings == null || halo == null) return; + + VoiceState state = VoiceChatBridge.state(); + long hold = holdMs.get(); + double time = (GameClock.millis() % 100000L) / 1000.0; + float size = indicatorSize.get(); + double maxSq = (double) maxDistance.get() * maxDistance.get(); + + for (VoiceState.Speaker speaker : state.speakers(hold, MAX_SPEAKERS)) { + Player player = mc.level.getPlayerByUUID(speaker.id()); + if (player == null || player == mc.player) continue; + Vec3 head = headPosition(player, tickDelta); + if (head.distanceToSqr(mc.player.position()) > maxSq) continue; + int argb = speaker.whisper() ? whisperColor.getArgb() : color.getArgb(); + VoiceIndicatorRenderer.speaking(rings, halo, head, size, state.level(speaker.id(), hold), argb, time, pulse.get()); + } + + if (showSelf.get() && !mc.options.getCameraType().isFirstPerson()) { + renderSelf(mc.player, state, rings, halo, hold, time, size, tickDelta); + } + } + + private void renderSelf(Player self, VoiceState state, MeshBuilder rings, MeshBuilder halo, + long hold, double time, float size, float tickDelta) { + Vec3 head = headPosition(self, tickDelta); + float level = state.selfLevel(hold); + if (level > 0f) { + int argb = state.isSelfWhispering(hold) ? whisperColor.getArgb() : selfColor.getArgb(); + VoiceIndicatorRenderer.speaking(rings, halo, head, size, level, argb, time, pulse.get()); + } + VoiceState.MicStatus mic = state.micStatus(hold); + boolean silenced = mic == VoiceState.MicStatus.MUTED || mic == VoiceState.MicStatus.DISABLED; + if (showMuted.get() && silenced) { + VoiceIndicatorRenderer.muted(rings, head, size * 0.45f, mutedColor.getArgb()); + } + } + + private static Vec3 headPosition(Player player, float tickDelta) { + Vec3 pos = RenderMath.getLerpedPos(player, tickDelta); + return new Vec3(pos.x, pos.y + player.getBbHeight() + HEAD_OFFSET, pos.z); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulse.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulse.java new file mode 100644 index 0000000..dafd9c7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulse.java @@ -0,0 +1,20 @@ +package dev.loki.lovisual.features.module.modules.misc.voice; + +/** Pure animation of the expanding rings drawn above a talking player. */ +final class VoiceIndicatorPulse { + static final int RINGS = 2; + private static final double PULSE_HZ = 0.9; + + private VoiceIndicatorPulse() { + } + + /** @param radius ring radius as a fraction of the indicator size; @param alpha opacity in [0, 1] */ + record Ring(float radius, float alpha) { } + + static Ring ring(double timeSec, int index, boolean pulse) { + double offset = (double) index / RINGS; + double phase = pulse ? (timeSec * PULSE_HZ + offset) % 1.0 : 0.35 + 0.3 * offset; + float p = (float) phase; + return new Ring(0.45f + 0.75f * p, pulse ? 1.0f - p : 0.6f - 0.25f * (float) offset); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorRenderer.java new file mode 100644 index 0000000..7e1e9d0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorRenderer.java @@ -0,0 +1,74 @@ +package dev.loki.lovisual.features.module.modules.misc.voice; + +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*; + +/** Draws the billboard glyphs of {@link VoiceChat}: a glowing halo with rings, and a crossed-out mic mark. */ +final class VoiceIndicatorRenderer { + private static final int RING_SEGMENTS = 24; + private static final float RING_WIDTH = 0.045f; + private static final float CROSS_WIDTH = 0.05f; + + private VoiceIndicatorRenderer() { + } + + /** @param ringMesh untextured additive mesh; @param haloMesh soft glow texture mesh */ + static void speaking(MeshBuilder ringMesh, MeshBuilder haloMesh, Vec3 at, float size, float level, + int argb, double timeSec, boolean pulse) { + addBillboardQuad(haloMesh, at.x, at.y, at.z, size * 0.9f, applyOpacity(argb, level * 0.55f)); + Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation); + Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation); + for (int i = 0; i < VoiceIndicatorPulse.RINGS; i++) { + VoiceIndicatorPulse.Ring ring = VoiceIndicatorPulse.ring(timeSec, i, pulse); + ring(ringMesh, at, right, up, size * ring.radius(), applyOpacity(argb, level * ring.alpha())); + } + } + + /** Red cross, used over your own head when the microphone is muted or disabled. */ + static void muted(MeshBuilder mesh, Vec3 at, float size, int argb) { + Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation).mul(size * 0.5f); + Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation).mul(size * 0.5f); + Vector3f normal = new Vector3f(right).cross(up).normalize(); + bar(mesh, at, new Vector3f(right).add(up), new Vector3f(right).sub(up), normal, size, argb); + bar(mesh, at, new Vector3f(right).sub(up), new Vector3f(right).add(up), normal, size, argb); + } + + private static void ring(MeshBuilder mesh, Vec3 at, Vector3f right, Vector3f up, float radius, int argb) { + float inner = Math.max(0f, radius - RING_WIDTH); + for (int i = 0; i < RING_SEGMENTS; i++) { + double a1 = i * Math.PI * 2.0 / RING_SEGMENTS; + double a2 = (i + 1) * Math.PI * 2.0 / RING_SEGMENTS; + mesh.ensureQuadCapacity(); + int v1 = point(mesh, at, right, up, a1, radius, argb); + int v2 = point(mesh, at, right, up, a2, radius, argb); + int v3 = point(mesh, at, right, up, a2, inner, argb); + int v4 = point(mesh, at, right, up, a1, inner, argb); + mesh.quad(v1, v2, v3, v4); + } + } + + private static int point(MeshBuilder mesh, Vec3 at, Vector3f right, Vector3f up, double angle, float r, int argb) { + float x = (float) Math.cos(angle) * r; + float y = (float) Math.sin(angle) * r; + return addVertex(mesh, at.x + right.x * x + up.x * y, at.y + right.y * x + up.y * y, + at.z + right.z * x + up.z * y, argb); + } + + /** Thin camera-facing quad along {@code from -> to} (offsets from {@code at}). */ + private static void bar(MeshBuilder mesh, Vec3 at, Vector3f from, Vector3f to, + Vector3f normal, float size, int argb) { + Vector3f side = new Vector3f(to).sub(from).cross(normal); + if (side.lengthSquared() < 1.0E-8f) return; + side.normalize(CROSS_WIDTH * size); + mesh.ensureQuadCapacity(); + int v1 = addVertex(mesh, at.x + from.x - side.x, at.y + from.y - side.y, at.z + from.z - side.z, argb); + int v2 = addVertex(mesh, at.x + from.x + side.x, at.y + from.y + side.y, at.z + from.z + side.z, argb); + int v3 = addVertex(mesh, at.x + to.x + side.x, at.y + to.y + side.y, at.z + to.z + side.z, argb); + int v4 = addVertex(mesh, at.x + to.x - side.x, at.y + to.y - side.y, at.z + to.z - side.z, argb); + mesh.quad(v1, v2, v3, v4); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java index f2f5b66..b7f5555 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java @@ -8,6 +8,7 @@ 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.modules.visuals.effects.hits.blood.BloodKillHelper; import dev.loki.lovisual.features.module.phase.WorldPhase; import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor; import dev.loki.lovisual.render.engine.renderer.Renderer3D; @@ -25,39 +26,46 @@ import java.util.Map; //todo Description @ModuleInfo(id = "killeffect", displayName = "KillEffect", category = ModuleCategory.VISUALS) public class KillEffect extends Module { - private static final String SETTING_MODE = "mode"; - private static final String SETTING_Y_SPEED = "y_speed"; - private static final String SETTING_PLAY_SOUND = "play_sound"; - private static final String SETTING_SOUND_VOLUME = "sound_volume"; - private static final String SETTING_COLOR = "color"; - private static final String SETTING_MOBS = "mobs"; - private static final String SETTING_BEAM_INTENSITY = "beam_intensity"; - private static final String SETTING_BEAM_SIZE = "beam_size"; private static final Identifier ORTHODOX_SOUND = Identifier.fromNamespaceAndPath("lovisual", "sounds/misc/orthodox.wav"); - private final ModeValue mode = modeSetting("killEffectMode", SETTING_MODE, "Orthodox", - "Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado"); + private final ModeValue mode = modeSetting("killEffectMode", "mode", "Orthodox", + "Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado", "Blood"); private final NumberValue ySpeed = visibleWhen( - num("killEffectYSpeed", SETTING_Y_SPEED, 0, -10, 10), () -> isMode("Orthodox")); + num("killEffectYSpeed", "y_speed", 0, -10, 10), () -> isMode("Orthodox")); private final BooleanValue playSound = visibleWhen( - bool("killEffectPlaySound", SETTING_PLAY_SOUND, true), () -> isMode("Orthodox")); + bool("killEffectPlaySound", "play_sound", true), () -> isMode("Orthodox")); private final NumberValue soundVolume = visibleWhen( - num("killEffectSoundVolume", SETTING_SOUND_VOLUME, 1.0, 0.0, 2.0), + num("killEffectSoundVolume", "sound_volume", 1.0, 0.0, 2.0), () -> isMode("Orthodox") && playSound.get()); private final RGBAColorValue color = visibleWhen( - color("killEffectColor", SETTING_COLOR, "#FFFF9600"), this::usesColorSetting); - private final BooleanValue mobs = bool("killEffectMobs", SETTING_MOBS, false); + color("killEffectColor", "color", "#FFFF9600"), this::usesColorSetting); + private final BooleanValue mobs = bool("killEffectMobs", "mobs", false); private final NumberValue beamIntensity = visibleWhen( - num("killEffectBeamIntensity", SETTING_BEAM_INTENSITY, 1.2f, 0.2f, 3.0f), this::usesBeamSettings); + num("killEffectBeamIntensity", "beam_intensity", 1.2f, 0.2f, 3.0f), this::usesBeamSettings); private final NumberValue beamSize = visibleWhen( - num("killEffectBeamSize", SETTING_BEAM_SIZE, 2.0f, 0.5f, 5.0f), this::usesBeamSettings); + num("killEffectBeamSize", "beam_size", 2.0f, 0.5f, 5.0f), this::usesBeamSettings); + + private final RGBAColorValue bloodColor = visibleWhen( + color("killEffectBloodColor", "blood_color", "#FF8A0303"), () -> isMode("Blood")); + private final ModeValue bloodStyle = visibleWhen( + modeSetting("killEffectBloodStyle", "blood_style", "Burst", "Burst", "Fountain", "Ring", "Spray"), + () -> isMode("Blood")); + private final NumberValue bloodAmount = visibleWhen( + num("killEffectBloodAmount", "blood_amount", 70, 10, 250), () -> isMode("Blood")); + private final NumberValue bloodPower = visibleWhen( + num("killEffectBloodPower", "blood_power", 1.0f, 0.3f, 2.5f), () -> isMode("Blood")); + private final NumberValue bloodSize = visibleWhen( + num("killEffectBloodSize", "blood_size", 1.0f, 0.3f, 3.0f), () -> isMode("Blood")); + private final NumberValue bloodTime = visibleWhen( + num("killEffectBloodTime", "blood_time", 6.0f, 1.0f, 30.0f), () -> isMode("Blood")); private final Map handled = new HashMap<>(); private final KillOrthodoxHelper orthodoxHelper = new KillOrthodoxHelper(); private final KillEmberHelper emberHelper = new KillEmberHelper(); private final KillLightningHelper lightningHelper = new KillLightningHelper(); private final KillBeamHelper beamHelper = new KillBeamHelper(); + private final BloodKillHelper bloodHelper = new BloodKillHelper(); private boolean isMode(String value) { return value.equals(mode.get()); @@ -107,6 +115,7 @@ public class KillEffect extends Module { beamHelper.tick(); lightningHelper.tick(); orthodoxHelper.tick(now); + bloodHelper.tick(mc.level, mc.player, bloodTime.get()); } private void handleKill(Vec3 pos, LivingEntity entity, long now) { @@ -126,6 +135,11 @@ public class KillEffect extends Module { beamHelper.add(pos, color.getArgb(), KillBeamHelper.BeamKind.PORTAL); } else if ("Tornado".equals(current)) { beamHelper.add(pos, color.getArgb(), KillBeamHelper.BeamKind.TORNADO); + } else if ("Blood".equals(current)) { + bloodHelper.spawn(new Vec3(pos.x, pos.y + entity.getBbHeight() * 0.6, pos.z), + Math.clamp(entity.getBbWidth(), 0.3f, 1.6f), Math.clamp(entity.getBbHeight(), 0.3f, 2.6f), + bloodAmount.get(), bloodPower.get(), bloodSize.get(), bloodColor.getArgb(), + bloodStyle.get(), pos.subtract(Minecraft.getInstance().player.position())); } } @@ -136,6 +150,7 @@ public class KillEffect extends Module { emberHelper.clear(); lightningHelper.clear(); beamHelper.clear(); + bloodHelper.clear(); } @Override @@ -153,6 +168,7 @@ public class KillEffect extends Module { emberHelper.render(renderer, tickDelta); } beamHelper.render(renderer, mc, beamIntensity.get(), beamSize.get()); + bloodHelper.render(renderer, mc.gameRenderer.mainCamera().position(), tickDelta, bloodTime.get()); } private void spawnFallingLava(LivingEntity entity) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodGeometry.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodGeometry.java new file mode 100644 index 0000000..879cba2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodGeometry.java @@ -0,0 +1,121 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood; + +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import net.minecraft.world.phys.Vec3; + +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addVertex; + +/** Builds the triangles of a teardrop (drop) and a ragged puddle (splat), plus color helpers. */ +final class BloodGeometry { + private static final int RING = 6; + private static final int SPLAT_SEGMENTS = 14; + + private BloodGeometry() { + } + + /** Teardrop: a 6-sided ring with a front cap and a speed-stretched, fading tail. */ + static void drop(MeshBuilder mesh, Vec3 p, Drop d) { + double speed = d.vel.length(); + Vec3 dir = speed < 1.0E-4 ? new Vec3(0, -1, 0) : d.vel.scale(1.0 / speed); + float r = d.size * 0.5f; + float head = r * 0.9f; + float tail = r * (1.6f + (float) Math.min(speed, 0.9) * 5.5f); + Vec3 up = Math.abs(dir.y) > 0.95 ? new Vec3(1, 0, 0) : new Vec3(0, 1, 0); + Vec3 s1 = dir.cross(up).normalize(); + Vec3 s2 = dir.cross(s1).normalize(); + Vec3 front = p.add(dir.scale(head)); + Vec3 back = p.subtract(dir.scale(tail)); + Vec3 center = p.add(dir.scale(head * 0.15)); + for (int i = 0; i < RING; i++) { + int j = (i + 1) % RING; + Vec3 n1 = ringNormal(s1, s2, i); + Vec3 n2 = ringNormal(s1, s2, j); + Vec3 n = n1.add(n2).normalize(); + int cf = lit(d.color, n.add(dir.scale(0.6)).normalize(), d.light, true); + int cb = lit(d.color, n.add(dir.scale(-0.3)).normalize(), d.light, false); + tri(mesh, front, center.add(n1.scale(r)), center.add(n2.scale(r)), cf, cf, cf); + tri(mesh, center.add(n1.scale(r)), back, center.add(n2.scale(r)), cb, withAlpha(shade(cb, 0.8f), 150), cb); + } + } + + /** Puddle: grows quickly, then fades and darkens as it dries. Returns false when finished. */ + static boolean splat(MeshBuilder mesh, Splat s, long nowMs, float lifeMs) { + long age = nowMs - s.startMs(); + float life = age / lifeMs; + float fade = 1.0f - smooth((life - 0.7f) / 0.3f); + if (life >= 1.0f || fade <= 0.01f) return false; + float grow = 1.0f - (float) Math.pow(1.0f - Math.min(1f, age / 260f), 3); + float dry = smooth(life / 0.8f); + float radius = s.radius() * (0.35f + 0.65f * grow); + int base = shade(s.color(), s.light() * (1.0f - 0.45f * dry)); + int centerCol = withAlpha(shade(base, 0.72f), Math.round(235 * fade)); + int edgeCol = withAlpha(base, Math.round(205 * fade)); + int rimCol = withAlpha(base, 0); + Vec3 c = new Vec3(s.pos().x, s.pos().y + 0.006 + s.layer() * 0.0004, s.pos().z); + for (int i = 0; i < SPLAT_SEGMENTS; i++) { + int j = (i + 1) % SPLAT_SEGMENTS; + float r1 = radius * (0.72f + noise(s.seed(), i) * 0.5f); + float r2 = radius * (0.72f + noise(s.seed(), j) * 0.5f); + double a1 = i * Math.PI * 2.0 / SPLAT_SEGMENTS; + double a2 = j * Math.PI * 2.0 / SPLAT_SEGMENTS; + Vec3 p1 = c.add(Math.cos(a1) * r1, 0, Math.sin(a1) * r1); + Vec3 p2 = c.add(Math.cos(a2) * r2, 0, Math.sin(a2) * r2); + Vec3 o1 = c.add(Math.cos(a1) * r1 * 1.18, 0, Math.sin(a1) * r1 * 1.18); + Vec3 o2 = c.add(Math.cos(a2) * r2 * 1.18, 0, Math.sin(a2) * r2 * 1.18); + tri(mesh, c, p1, p2, centerCol, edgeCol, edgeCol); + tri(mesh, p1, o1, o2, edgeCol, rimCol, rimCol); + tri(mesh, p1, o2, p2, edgeCol, rimCol, edgeCol); + } + return true; + } + + private static Vec3 ringNormal(Vec3 s1, Vec3 s2, int i) { + double a = i * Math.PI * 2.0 / RING; + return s1.scale(Math.cos(a)).add(s2.scale(Math.sin(a))); + } + + private static void tri(MeshBuilder m, Vec3 a, Vec3 b, Vec3 c, int ca, int cb, int cc) { + m.ensureTriCapacity(); + m.triangle(addVertex(m, a.x, a.y, a.z, ca), addVertex(m, b.x, b.y, b.z, cb), addVertex(m, c.x, c.y, c.z, cc)); + } + + private static int lit(int color, Vec3 normal, float light, boolean highlight) { + double dot = Math.max(0.0, normal.x * 0.35 + normal.y * 0.85 + normal.z * 0.25); + int c = shade(color, (float) (0.45 + 0.75 * dot) * light); + return highlight && dot > 0.8 ? mix(c, 0xFFFF9A9A, (float) ((dot - 0.8) / 0.2) * 0.45f * light) : c; + } + + static int shade(int color, float k) { + int r = Math.min(255, Math.round(((color >> 16) & 255) * k)); + int g = Math.min(255, Math.round(((color >> 8) & 255) * k)); + int b = Math.min(255, Math.round((color & 255) * k)); + return (color & 0xFF000000) | (r << 16) | (g << 8) | b; + } + + static int mix(int first, int second, float t) { + float k = Math.max(0f, Math.min(1f, t)); + int r = Math.round(((first >> 16) & 255) * (1 - k) + ((second >> 16) & 255) * k); + int g = Math.round(((first >> 8) & 255) * (1 - k) + ((second >> 8) & 255) * k); + int b = Math.round((first & 255) * (1 - k) + (second & 255) * k); + return (first & 0xFF000000) | (r << 16) | (g << 8) | b; + } + + static int withAlpha(int color, int alpha) { + return (Math.max(0, Math.min(255, alpha)) << 24) | (color & 0x00FFFFFF); + } + + static float smooth(float v) { + float t = Math.max(0f, Math.min(1f, v)); + return t * t * (3f - 2f * t); + } + + /** Deterministic per-puddle noise in [0, 1]. */ + static float noise(long seed, int salt) { + long v = seed + 0x9E3779B97F4A7C15L * (salt + 1L); + v = (v ^ (v >>> 30)) * 0xBF58476D1CE4E5B9L; + v = (v ^ (v >>> 27)) * 0x94D049BB133111EBL; + return ((v ^ (v >>> 31)) >>> 40) / 16777215.0f; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodKillHelper.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodKillHelper.java new file mode 100644 index 0000000..b46d7ad --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodKillHelper.java @@ -0,0 +1,104 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood; + +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** KillEffect "Blood" mode: a burst of physically simulated drops that stain the floor. */ +public final class BloodKillHelper { + private static final int MAX_DROPS = 900; + private static final int MAX_SPLATS = 700; + private static final double RANGE_SQ = 96.0 * 96.0; + + private final Random random = new Random(); + private final List drops = new ArrayList<>(); + private final List splats = new ArrayList<>(); + + /** @param style BURST, FOUNTAIN, RING or SPRAY (case-insensitive); {@code away} points attacker to victim */ + public void spawn(Vec3 center, float width, float height, int count, float power, float scale, int color, + String style, Vec3 away) { + Vec3 flat = new Vec3(away.x, 0, away.z); + Vec3 dir = flat.lengthSqr() < 1.0E-6 ? new Vec3(1, 0, 0) : flat.normalize(); + BloodPhysics.burst(drops, center, width, height, count, power, scale, color, + BloodPhysics.Style.of(style), dir, random); + trim(drops, MAX_DROPS); + } + + public void tick(ClientLevel level, Entity viewer, float splatSeconds) { + long now = GameClock.millis(); + long life = (long) (splatSeconds * 1000f); + splats.removeIf(s -> now - s.startMs() > life); + if (drops.isEmpty()) return; + + BloodPhysics.Surface surface = surface(level, viewer); + List children = new ArrayList<>(); + drops.removeIf(d -> !BloodPhysics.step(d, surface, splats, children, now, random)); + drops.addAll(children); + trim(drops, MAX_DROPS); + trim(splats, MAX_SPLATS); + } + + public void render(Renderer3D renderer, Vec3 camera, float tickDelta, float splatSeconds) { + if (renderer == null || (drops.isEmpty() && splats.isEmpty())) return; + MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN); + if (mesh == null) return; + + long now = GameClock.millis(); + float lifeMs = splatSeconds * 1000f; + for (Splat s : splats) { + if (s.pos().distanceToSqr(camera) <= RANGE_SQ) { + BloodGeometry.splat(mesh, s, now, lifeMs); + } + } + float t = Math.max(0f, Math.min(1f, tickDelta)); + for (Drop d : drops) { + Vec3 p = d.prev.add(d.pos.subtract(d.prev).scale(t)); + if (p.distanceToSqr(camera) <= RANGE_SQ) { + BloodGeometry.drop(mesh, p, d); + } + } + } + + public void clear() { + drops.clear(); + splats.clear(); + } + + private static BloodPhysics.Surface surface(ClientLevel level, Entity viewer) { + return new BloodPhysics.Surface() { + @Override + public BloodPhysics.Hit cast(Vec3 from, Vec3 to) { + BlockHitResult hit = level.clip(new ClipContext(from, to, + ClipContext.Block.COLLIDER, ClipContext.Fluid.ANY, viewer)); + if (hit == null || hit.getType() != HitResult.Type.BLOCK) return null; + return new BloodPhysics.Hit(hit.getLocation(), hit.getDirection().getStepX(), + hit.getDirection().getStepY(), hit.getDirection().getStepZ()); + } + + @Override + public float light(Vec3 pos) { + return 0.3f + 0.7f * (level.getMaxLocalRawBrightness(BlockPos.containing(pos)) / 15.0f); + } + }; + } + + private static void trim(List list, int max) { + if (list.size() > max) { + list.subList(0, list.size() - max).clear(); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodParticles.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodParticles.java new file mode 100644 index 0000000..145e3d5 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodParticles.java @@ -0,0 +1,34 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood; + +import net.minecraft.world.phys.Vec3; + +/** Mutable blood drop in flight and immutable floor splat. */ +final class BloodParticles { + private BloodParticles() { + } + + static final class Drop { + Vec3 pos; + Vec3 prev; + Vec3 vel; + final float size; + final int color; + final boolean child; + final float scale; + float light = 1.0f; + int age; + + Drop(Vec3 pos, Vec3 vel, float size, int color, float scale, boolean child) { + this.pos = pos; + this.prev = pos; + this.vel = vel; + this.size = size; + this.color = color | 0xFF000000; + this.scale = scale; + this.child = child; + } + } + + record Splat(Vec3 pos, float radius, int color, long startMs, long seed, float light, int layer) { + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysics.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysics.java new file mode 100644 index 0000000..afbfcbe --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysics.java @@ -0,0 +1,129 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood; + +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat; +import net.minecraft.world.phys.Vec3; + +import java.util.List; +import java.util.Random; + +/** Drop physics: ballistic flight, wall bounces, floor splats and secondary droplets. */ +final class BloodPhysics { + static final int MAX_FLIGHT_TICKS = 120; + private static final double GRAVITY = 0.045; + private static final double DRAG = 0.985; + + private BloodPhysics() { + } + + record Hit(Vec3 at, int stepX, int stepY, int stepZ) { + } + + /** World access, kept abstract so the physics stays testable. */ + interface Surface { + /** First solid block hit on the segment, or null. */ + Hit cast(Vec3 from, Vec3 to); + + /** Brightness multiplier in [0.3, 1]. */ + float light(Vec3 pos); + } + + /** Shape of the initial blood eruption. */ + enum Style { + BURST, FOUNTAIN, RING, SPRAY; + + static Style of(String name) { + for (Style s : values()) { + if (s.name().equalsIgnoreCase(name)) return s; + } + return BURST; + } + } + + /** + * @param away horizontal unit vector pointing from the attacker to the victim, used by SPRAY + */ + static void burst(List out, Vec3 center, float width, float height, int count, float power, + float scale, int color, Style style, Vec3 away, Random rnd) { + for (int i = 0; i < count; i++) { + Vec3 origin = center.add((rnd.nextDouble() - 0.5) * width * 0.6, + (rnd.nextDouble() - 0.5) * height * 0.5, (rnd.nextDouble() - 0.5) * width * 0.6); + float size = (float) (0.025 + Math.pow(rnd.nextDouble(), 2.2) * 0.075) * scale; + int shade = BloodGeometry.shade(color, 0.82f + rnd.nextFloat() * 0.3f); + out.add(new Drop(origin, velocity(style, power, away, rnd), size, shade, scale, false)); + } + } + + private static Vec3 velocity(Style style, double power, Vec3 away, Random rnd) { + double angle = rnd.nextDouble() * Math.PI * 2.0; + double cos = Math.cos(angle); + double sin = Math.sin(angle); + return switch (style) { + case FOUNTAIN -> { + double h = (0.02 + rnd.nextDouble() * 0.07) * power; + yield new Vec3(cos * h, (0.25 + rnd.nextDouble() * 0.35) * power * 1.6, sin * h); + } + case RING -> { + double h = (0.18 + rnd.nextDouble() * 0.12) * power; + yield new Vec3(cos * h, (0.04 + rnd.nextDouble() * 0.05) * power, sin * h); + } + case SPRAY -> { + double forward = (0.12 + rnd.nextDouble() * 0.25) * power; + double side = (rnd.nextDouble() - 0.5) * 0.35 * power; + yield new Vec3(away.x * forward - away.z * side, (0.05 + rnd.nextDouble() * 0.2) * power, + away.z * forward + away.x * side); + } + default -> { + double h = (0.05 + rnd.nextDouble() * 0.24) * power; + yield new Vec3(cos * h, (0.06 + rnd.nextDouble() * 0.34) * power, sin * h); + } + }; + } + + /** Advances one tick; returns false once the drop is spent (landed or expired). */ + static boolean step(Drop d, Surface surface, List splats, List children, long nowMs, Random rnd) { + d.prev = d.pos; + if (++d.age > MAX_FLIGHT_TICKS) return false; + + Vec3 vel = new Vec3(d.vel.x * DRAG, (d.vel.y - GRAVITY) * DRAG, d.vel.z * DRAG); + Vec3 next = d.pos.add(vel); + Hit hit = surface.cast(d.pos, next); + if (hit == null) { + d.pos = next; + d.vel = vel; + if ((d.age & 3) == 0) d.light = surface.light(next); + return true; + } + if (hit.stepY() > 0) { + land(d, hit.at(), vel, surface, splats, children, nowMs, rnd); + return false; + } + d.vel = bounce(hit, vel); + d.pos = hit.at().add(hit.stepX() * 0.01, hit.stepY() * 0.01, hit.stepZ() * 0.01); + return true; + } + + private static Vec3 bounce(Hit hit, Vec3 vel) { + if (hit.stepY() < 0) { + return new Vec3(vel.x * 0.4, -Math.abs(vel.y) * 0.1, vel.z * 0.4); + } + double vx = hit.stepX() != 0 ? -vel.x * 0.12 : vel.x * 0.3; + double vz = hit.stepZ() != 0 ? -vel.z * 0.12 : vel.z * 0.3; + return new Vec3(vx, Math.min(vel.y, 0.0) * 0.5, vz); + } + + private static void land(Drop d, Vec3 at, Vec3 vel, Surface surface, List splats, + List children, long nowMs, Random rnd) { + float speed = (float) vel.length(); + float radius = d.size * (2.2f + Math.min(speed, 0.8f) * 4.0f); + splats.add(new Splat(at, radius, d.color, nowMs, rnd.nextLong(), surface.light(at), rnd.nextInt(40))); + if (d.child || d.size <= 0.055f * d.scale || speed <= 0.25f) return; + int n = 2 + rnd.nextInt(3); + for (int k = 0; k < n; k++) { + double a = rnd.nextDouble() * Math.PI * 2.0; + double h = 0.03 + rnd.nextDouble() * 0.07; + Vec3 v = new Vec3(Math.cos(a) * h + vel.x * 0.25, 0.05 + rnd.nextDouble() * 0.08, Math.sin(a) * h + vel.z * 0.25); + children.add(new Drop(at.add(0, 0.02, 0), v, d.size * 0.35f, d.color, d.scale, true)); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java index f3d07a3..c8d078f 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java @@ -1,7 +1,9 @@ package dev.loki.lovisual.features.module.modules.visuals.items.held; +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.modules.visuals.items.held.lua.HandScripts; import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; @@ -29,6 +31,11 @@ import net.minecraft.world.InteractionHand; public final class HoldMyItems extends Module { private final HandAnimState handAnim = new HandAnimState(); + private HandScripts scripts; + + /** Dynamics: Java-динамика; Scripts: Lua из config/lovisual/holdmyitems; Both: оба подряд. */ + public final ModeValue handMode = + modeSetting("holdMyItemsMode", "mode", "Dynamics", "Dynamics", "Scripts", "Both"); public final NumberValue swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f); public final NumberValue easeStrength = num("ease_strength", 1.6f, 0.5f, 3.5f); @@ -48,6 +55,20 @@ public final class HoldMyItems extends Module { public final NumberValue gripTurn = visibleWhen(num("grip_turn", 0.0f, -90.0f, 90.0f), gripEnabled::get); public final NumberValue gripRoll = visibleWhen(num("grip_roll", 0.0f, -180.0f, 180.0f), gripEnabled::get); + public boolean dynamicsActive() { + return !"Scripts".equals(handMode.get()); + } + + public boolean scriptsActive() { + return !"Dynamics".equals(handMode.get()); + } + + /** Рантайм Lua-скриптов; создаётся лениво, чтобы LuaJ не грузился в режиме Dynamics. */ + public HandScripts scripts() { + if (scripts == null) scripts = new HandScripts(); + return scripts; + } + public HandAnimState handAnim() { return handAnim; } @@ -71,6 +92,7 @@ public final class HoldMyItems extends Module { @Override public void onEnable() { handAnim.reset(); + if (scripts != null) scripts.reload(); } /** Перекладывает линейный equip-прогресс через ease-out-back с настройками модуля. */ diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandFrame.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandFrame.java new file mode 100644 index 0000000..512eaeb --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandFrame.java @@ -0,0 +1,18 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** Контекст кадра руки, который видят Lua-скрипты (как `context` в HoldMyItems). */ +public record HandFrame( + Player player, + ItemStack stack, + boolean mainHand, + boolean rightArm, + float swingProgress, + float equipProgress, + float tickDelta, + PoseStack pose +) { +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java new file mode 100644 index 0000000..b3abf17 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java @@ -0,0 +1,175 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua; + +import com.mojang.blaze3d.vertex.PoseStack; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.Easings; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.EntityApi; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.PoseApi; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaSandbox; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.ScriptSlot; +import net.fabricmc.loader.api.FabricLoader; +import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.HumanoidArm; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemDisplayContext; +import net.minecraft.world.item.ItemStack; +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Скрипты руки: {@code hand_pose.lua} (поза всей руки) и {@code item_pose.lua} (поза предмета). + * Источник: {@code config/lovisual/holdmyitems/.lua} (горячая перезагрузка по mtime раз в + * секунду), иначе встроенный пример из ресурсов мода. Ошибка скрипта пишется в лог один раз и + * отключает только этот скрипт до правки файла. + */ +public final class HandScripts { + + public static final String HAND_POSE = "hand_pose"; + public static final String ITEM_POSE = "item_pose"; + + private static final Logger LOG = LoggerFactory.getLogger(HandScripts.class); + private static final long RECHECK_MS = 1000L; + + private final LuaSandbox sandbox = new LuaSandbox(); + private final PoseApi pose = new PoseApi(); + private final long[] stamps = {Long.MIN_VALUE, Long.MIN_VALUE}; + private final ScriptSlot[] slots = new ScriptSlot[2]; + private final float[][] lastState = new float[2][2]; + private long lastCheck; + private Player player; + private ItemStack stack = ItemStack.EMPTY; + + public HandScripts() { + Globals g = sandbox.globals(); + g.set("M", pose.table()); + g.set("P", EntityApi.player(() -> player)); + g.set("I", EntityApi.item(() -> stack)); + LuaTable easings = new LuaTable(); + Easings.all().forEach((name, f) -> LuaTables.put(easings, name, a -> LuaValue.valueOf(f.applyAsDouble(LuaTables.num(a, 2, 0))))); + g.set("Easings", easings); + } + + /** Применяет hand_pose к матрице руки; true, если был pushPose и нужен popPose. */ + public static boolean applyHandPose(HoldMyItems module, AbstractClientPlayer player, InteractionHand hand, + float swing, float equip, float tickDelta, ItemStack item, PoseStack ms) { + if (!module.scriptsActive()) return false; + boolean mainHand = hand == InteractionHand.MAIN_HAND; + boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand; + ms.pushPose(); + module.scripts().runHand(new HandFrame(player, item, mainHand, rightArm, swing, equip, tickDelta, ms)); + return true; + } + + /** Применяет item_pose к матрице предмета; true, если был pushPose и нужен popPose. */ + public static boolean applyItemPose(HoldMyItems module, LivingEntity entity, ItemStack stack, + ItemDisplayContext ctx, PoseStack ms) { + if (!ctx.firstPerson() || !(entity instanceof AbstractClientPlayer player)) return false; + if (!module.isEnabled() || !module.scriptsActive()) return false; + boolean rightArm = ctx == ItemDisplayContext.FIRST_PERSON_RIGHT_HAND; + boolean mainHand = (player.getMainArm() == HumanoidArm.RIGHT) == rightArm; + HandScripts scripts = module.scripts(); + float[] last = scripts.lastState(mainHand); + ms.pushPose(); + scripts.runItem(new HandFrame(player, stack, mainHand, rightArm, last[0], last[1], 0.0f, ms)); + return true; + } + + public static Path scriptDir() { + return FabricLoader.getInstance().getConfigDir().resolve("lovisual").resolve("holdmyitems"); + } + + /** Принудительная перезагрузка на следующем кадре. */ + public void reload() { + stamps[0] = Long.MIN_VALUE; + stamps[1] = Long.MIN_VALUE; + lastCheck = 0L; + } + + public void runHand(HandFrame f) { + run(0, HAND_POSE, f); + lastState[f.mainHand() ? 0 : 1][0] = f.swingProgress(); + lastState[f.mainHand() ? 0 : 1][1] = f.equipProgress(); + } + + public void runItem(HandFrame f) { + run(1, ITEM_POSE, f); + } + + /** Последний известный swing/equip руки: item_pose рендерится вне submitArmWithItem. */ + public float[] lastState(boolean mainHand) { + return lastState[mainHand ? 0 : 1]; + } + + private void run(int index, String name, HandFrame f) { + refresh(); + ScriptSlot slot = slots[index]; + if (slot == null || !slot.canRun()) return; + this.player = f.player(); + this.stack = f.stack(); + Globals g = sandbox.globals(); + g.set("registry", slot.registryTable()); + g.set("mainHand", LuaValue.valueOf(f.mainHand())); + g.set("rightArm", LuaValue.valueOf(f.rightArm())); + g.set("swingProgress", LuaValue.valueOf(f.swingProgress())); + g.set("equipProgress", LuaValue.valueOf(f.equipProgress())); + g.set("tickDelta", LuaValue.valueOf(f.tickDelta())); + g.set("usingItem", LuaValue.valueOf(f.player().isUsingItem())); + g.set("useDuration", LuaValue.valueOf(f.player().getTicksUsingItem())); + g.set("time", LuaValue.valueOf(System.nanoTime() / 1.0e9)); + pose.bind(f.pose()); + try { + slot.run(); + } finally { + pose.finish(); + } + if (!slot.canRun()) LOG.warn("HoldMyItems script {} disabled: {}", name, slot.error()); + } + + private void refresh() { + long now = System.currentTimeMillis(); + if (now - lastCheck < RECHECK_MS) return; + lastCheck = now; + String[] names = {HAND_POSE, ITEM_POSE}; + for (int i = 0; i < names.length; i++) { + Path file = scriptDir().resolve(names[i] + ".lua"); + long stamp = stampOf(file); + if (stamp == stamps[i]) continue; + stamps[i] = stamp; + String source = read(file, names[i]); + slots[i] = source == null ? null : new ScriptSlot(names[i], source, sandbox); + if (slots[i] != null && !slots[i].canRun()) LOG.warn("HoldMyItems script {}: {}", names[i], slots[i].error()); + } + } + + private static long stampOf(Path file) { + try { + return Files.isRegularFile(file) ? Files.getLastModifiedTime(file).toMillis() : -1L; + } catch (IOException e) { + return -1L; + } + } + + private static String read(Path file, String name) { + try { + if (Files.isRegularFile(file)) return Files.readString(file, StandardCharsets.UTF_8); + try (InputStream in = HandScripts.class.getResourceAsStream("/assets/lovisual/holdmyitems/" + name + ".lua")) { + return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException e) { + LOG.warn("Cannot read HoldMyItems script {}: {}", name, e.getMessage()); + return null; + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/Easings.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/Easings.java new file mode 100644 index 0000000..6f1f882 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/Easings.java @@ -0,0 +1,68 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api; + +import java.util.function.DoubleUnaryOperator; + +/** Функции плавности для Lua-скриптов (`Easings:easeOutBack(t)`), t в [0,1]. */ +public final class Easings { + + private static final double C1 = 1.70158; + private static final double C3 = C1 + 1.0; + + private Easings() { + } + + public static double outQuad(double t) { + return 1 - (1 - t) * (1 - t); + } + + public static double inOutCubic(double t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + + public static double outCubic(double t) { + return 1 - Math.pow(1 - t, 3); + } + + public static double inOutSine(double t) { + return -(Math.cos(Math.PI * t) - 1) / 2; + } + + public static double outBack(double t) { + return 1 + C3 * Math.pow(t - 1, 3) + C1 * Math.pow(t - 1, 2); + } + + public static double inOutBack(double t) { + double c2 = C1 * 1.525; + return t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; + } + + public static double outElastic(double t) { + if (t <= 0 || t >= 1) return Math.max(0, Math.min(1, t)); + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1; + } + + public static double outBounce(double t) { + double n = 7.5625, d = 2.75; + if (t < 1 / d) return n * t * t; + if (t < 2 / d) { t -= 1.5 / d; return n * t * t + 0.75; } + if (t < 2.5 / d) { t -= 2.25 / d; return n * t * t + 0.9375; } + t -= 2.625 / d; + return n * t * t + 0.984375; + } + + /** Имя функции в Lua и сама функция. */ + public static java.util.Map all() { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("easeOutQuad", Easings::outQuad); + m.put("easeInOutCubic", Easings::inOutCubic); + m.put("easeOutCubic", Easings::outCubic); + m.put("easeInOutSine", Easings::inOutSine); + m.put("easeOutBack", Easings::outBack); + m.put("easeInOutBack", Easings::inOutBack); + m.put("easeOutElastic", Easings::outElastic); + m.put("easeOutBounce", Easings::outBounce); + return m; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/EntityApi.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/EntityApi.java new file mode 100644 index 0000000..6278488 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/EntityApi.java @@ -0,0 +1,64 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.tags.ItemTags; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +import java.util.function.Supplier; + +/** Lua-глобалы {@code P} (игрок) и {@code I} (предмет текущей руки), только чтение. */ +public final class EntityApi { + + private EntityApi() { + } + + public static LuaTable player(Supplier p) { + LuaTable t = new LuaTable(); + LuaTables.put(t, "getSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().horizontalDistance())); + LuaTables.put(t, "getXSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().x)); + LuaTables.put(t, "getYSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().y)); + LuaTables.put(t, "getZSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().z)); + LuaTables.put(t, "getYaw", a -> LuaValue.valueOf(p.get().getYRot())); + LuaTables.put(t, "getPitch", a -> LuaValue.valueOf(p.get().getXRot())); + LuaTables.put(t, "getAge", a -> LuaValue.valueOf(p.get().tickCount)); + LuaTables.put(t, "getHealth", a -> LuaValue.valueOf(p.get().getHealth())); + LuaTables.put(t, "isOnGround", a -> LuaValue.valueOf(p.get().onGround())); + LuaTables.put(t, "isSneaking", a -> LuaValue.valueOf(p.get().isShiftKeyDown())); + LuaTables.put(t, "isSprinting", a -> LuaValue.valueOf(p.get().isSprinting())); + LuaTables.put(t, "isSwimming", a -> LuaValue.valueOf(p.get().isSwimming())); + LuaTables.put(t, "isUsingItem", a -> LuaValue.valueOf(p.get().isUsingItem())); + LuaTables.put(t, "isTouchingWater", a -> LuaValue.valueOf(p.get().isInWater())); + LuaTables.put(t, "isClimbing", a -> LuaValue.valueOf(p.get().onClimbable())); + LuaTables.put(t, "hasVehicle", a -> LuaValue.valueOf(p.get().isPassenger())); + return t; + } + + public static LuaTable item(Supplier s) { + LuaTable t = new LuaTable(); + LuaTables.put(t, "isEmpty", a -> LuaValue.valueOf(s.get().isEmpty())); + LuaTables.put(t, "isOf", a -> LuaValue.valueOf(id(s.get()).equals(withNamespace(a.arg(2).tojstring())))); + LuaTables.put(t, "getName", a -> LuaValue.valueOf(id(s.get()))); + LuaTables.put(t, "isBlock", a -> LuaValue.valueOf(s.get().getItem() instanceof net.minecraft.world.item.BlockItem)); + LuaTables.put(t, "isEnchanted", a -> LuaValue.valueOf(s.get().isEnchanted())); + LuaTables.put(t, "isSword", a -> LuaValue.valueOf(s.get().is(ItemTags.SWORDS))); + LuaTables.put(t, "getUseAction", a -> LuaValue.valueOf(useAction(s.get()))); + return t; + } + + static String id(ItemStack stack) { + return BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + } + + static String withNamespace(String name) { + return name.indexOf(':') >= 0 ? name : "minecraft:" + name; + } + + private static String useAction(ItemStack stack) { + ItemUseAnimation anim = stack.getUseAnimation(); + return anim.name().toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/LuaTables.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/LuaTables.java new file mode 100644 index 0000000..747db39 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/LuaTables.java @@ -0,0 +1,32 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api; + +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.VarArgFunction; + +/** Сборка Lua-таблиц из Java-лямбд. Вызовы `T:name(x)` приходят с self в arg(1). */ +public final class LuaTables { + + @FunctionalInterface + public interface Fn { + LuaValue call(Varargs args); + } + + private LuaTables() { + } + + public static void put(LuaTable table, String name, Fn fn) { + table.set(name, new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + return fn.call(args); + } + }); + } + + /** Число-аргумент по индексу (учитывая self в arg(1)); отсутствует = def. */ + public static double num(Varargs a, int i, double def) { + return a.arg(i).isnil() ? def : a.arg(i).todouble(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java new file mode 100644 index 0000000..c353222 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java @@ -0,0 +1,89 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +/** + * Lua-глобал {@code M}: преобразования матрицы руки/предмета (moveX/Y/Z, rotateX/Y/Z с pivot, + * scale, push/pop) и математика. PoseStack подставляется на каждый вызов скрипта; лишние push + * закрываются {@link #finish()}, лишние pop игнорируются. + */ +public final class PoseApi { + + private PoseStack stack; + private int depth; + + public PoseApi bind(PoseStack stack) { + this.stack = stack; + this.depth = 0; + return this; + } + + /** Закрывает push, оставленные скриптом незакрытыми. */ + public void finish() { + while (stack != null && depth > 0) { + stack.popPose(); + depth--; + } + stack = null; + } + + private void rotate(Axis axis, double deg, double px, double py, double pz) { + if (stack == null) return; + stack.translate((float) px, (float) py, (float) pz); + stack.mulPose(axis.rotationDegrees((float) deg)); + stack.translate((float) -px, (float) -py, (float) -pz); + } + + public LuaTable table() { + LuaTable t = new LuaTable(); + LuaTables.put(t, "moveX", a -> move(LuaTables.num(a, 2, 0), 0, 0)); + LuaTables.put(t, "moveY", a -> move(0, LuaTables.num(a, 2, 0), 0)); + LuaTables.put(t, "moveZ", a -> move(0, 0, LuaTables.num(a, 2, 0))); + LuaTables.put(t, "translate", a -> move(LuaTables.num(a, 2, 0), LuaTables.num(a, 3, 0), LuaTables.num(a, 4, 0))); + LuaTables.put(t, "rotateX", a -> rot(Axis.XP, a)); + LuaTables.put(t, "rotateY", a -> rot(Axis.YP, a)); + LuaTables.put(t, "rotateZ", a -> rot(Axis.ZP, a)); + LuaTables.put(t, "scale", a -> { + if (stack != null) { + double x = LuaTables.num(a, 2, 1); + stack.scale((float) x, (float) LuaTables.num(a, 3, x), (float) LuaTables.num(a, 4, x)); + } + return LuaValue.NIL; + }); + LuaTables.put(t, "push", a -> { + if (stack != null) { stack.pushPose(); depth++; } + return LuaValue.NIL; + }); + LuaTables.put(t, "pop", a -> { + if (stack != null && depth > 0) { stack.popPose(); depth--; } + return LuaValue.NIL; + }); + LuaTables.put(t, "sin", a -> LuaValue.valueOf(Math.sin(LuaTables.num(a, 2, 0)))); + LuaTables.put(t, "cos", a -> LuaValue.valueOf(Math.cos(LuaTables.num(a, 2, 0)))); + LuaTables.put(t, "abs", a -> LuaValue.valueOf(Math.abs(LuaTables.num(a, 2, 0)))); + LuaTables.put(t, "round", a -> LuaValue.valueOf((double) Math.round(LuaTables.num(a, 2, 0)))); + LuaTables.put(t, "floor", a -> LuaValue.valueOf(Math.floor(LuaTables.num(a, 2, 0)))); + LuaTables.put(t, "ceil", a -> LuaValue.valueOf(Math.ceil(LuaTables.num(a, 2, 0)))); + LuaTables.put(t, "pow", a -> LuaValue.valueOf(Math.pow(LuaTables.num(a, 2, 0), LuaTables.num(a, 3, 1)))); + LuaTables.put(t, "clamp", a -> LuaValue.valueOf( + Math.max(LuaTables.num(a, 3, 0), Math.min(LuaTables.num(a, 4, 1), LuaTables.num(a, 2, 0))))); + LuaTables.put(t, "lerp", a -> { + double from = LuaTables.num(a, 2, 0); + return LuaValue.valueOf(from + (LuaTables.num(a, 3, 0) - from) * LuaTables.num(a, 4, 0)); + }); + return t; + } + + private LuaValue move(double x, double y, double z) { + if (stack != null) stack.translate((float) x, (float) y, (float) z); + return LuaValue.NIL; + } + + private LuaValue rot(Axis axis, org.luaj.vm2.Varargs a) { + rotate(axis, LuaTables.num(a, 2, 0), LuaTables.num(a, 3, 0), LuaTables.num(a, 4, 0), LuaTables.num(a, 5, 0)); + return LuaValue.NIL; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaPreprocessor.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaPreprocessor.java new file mode 100644 index 0000000..ad5bed7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaPreprocessor.java @@ -0,0 +1,37 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Препроцессор скриптов: строка {@code global.x = v;} становится локальной переменной, + * которая берётся из {@code registry} (живёт между кадрами), а в конец скрипта дописывается + * сохранение значений обратно. Так скрипту не нужны собственные глобальные таблицы. + */ +public final class LuaPreprocessor { + + private static final Pattern GLOBAL_DECL = + Pattern.compile("(?m)^[ \\t]*global\\.([A-Za-z_][A-Za-z0-9_]*)[ \\t]*=[ \\t]*(.+?)[ \\t]*;?[ \\t]*$"); + + private LuaPreprocessor() { + } + + public static String process(String source) { + List names = new ArrayList<>(); + Matcher m = GLOBAL_DECL.matcher(source); + StringBuilder out = new StringBuilder(); + while (m.find()) { + String name = m.group(1); + names.add(name); + String line = "local " + name + " = registry:getOrDefault('" + name + "', " + m.group(2) + ")"; + m.appendReplacement(out, Matcher.quoteReplacement(line)); + } + m.appendTail(out); + for (String name : names) { + out.append("\nregistry:put('").append(name).append("', ").append(name).append(')'); + } + return out.toString(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaSandbox.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaSandbox.java new file mode 100644 index 0000000..d60fe3e --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/LuaSandbox.java @@ -0,0 +1,65 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime; + +import org.luaj.vm2.Globals; +import org.luaj.vm2.LoadState; +import org.luaj.vm2.LuaError; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.compiler.LuaC; +import org.luaj.vm2.lib.BaseLib; +import org.luaj.vm2.lib.Bit32Lib; +import org.luaj.vm2.lib.DebugLib; +import org.luaj.vm2.lib.PackageLib; +import org.luaj.vm2.lib.StringLib; +import org.luaj.vm2.lib.TableLib; +import org.luaj.vm2.lib.VarArgFunction; +import org.luaj.vm2.lib.jse.JseMathLib; + +/** + * Песочница LuaJ: только base/bit32/table/string/math. Нет io, os, luajava, package, файловых + * загрузчиков и debug; Java-объекты в Lua не отдаются вообще (API собирается из таблиц с функциями). + * Бесконечные циклы обрезаются счётчиком инструкций через хук, который ставится один раз. + */ +public final class LuaSandbox { + + /** Шаг хука в инструкциях и допустимое число шагов на один запуск (≈ 400k инструкций). */ + private static final int HOOK_STEP = 1000; + private static final int HOOK_LIMIT = 400; + private static final String[] REMOVED = {"package", "require", "dofile", "loadfile", "load", "loadstring", "debug", "collectgarbage"}; + + private final Globals globals; + private int budget; + + public LuaSandbox() { + Globals g = new Globals(); + g.load(new BaseLib()); + g.load(new PackageLib()); + g.load(new Bit32Lib()); + g.load(new TableLib()); + g.load(new StringLib()); + g.load(new JseMathLib()); + g.load(new DebugLib()); + LoadState.install(g); + LuaC.install(g); + LuaValue hook = new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + if (++budget > HOOK_LIMIT) throw new LuaError("script is too slow (instruction limit)"); + return NONE; + } + }; + g.get("debug").get("sethook").invoke(LuaValue.varargsOf( + new LuaValue[]{hook, LuaValue.valueOf(""), LuaValue.valueOf(HOOK_STEP)})); + for (String name : REMOVED) g.set(name, LuaValue.NIL); + this.globals = g; + } + + public Globals globals() { + return globals; + } + + /** Сбрасывает бюджет инструкций перед очередным запуском скрипта. */ + public void beginRun() { + budget = 0; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptSlot.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptSlot.java new file mode 100644 index 0000000..8562901 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptSlot.java @@ -0,0 +1,81 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime; + +import org.luaj.vm2.LuaError; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.lib.VarArgFunction; +import org.luaj.vm2.Varargs; + +import java.util.HashMap; +import java.util.Map; + +/** + * Один загруженный скрипт: скомпилированный чанк, свой {@code registry} и флаг «упал». + * После ошибки скрипт не запускается, пока его не перезагрузят (правка файла или реинициализация). + */ +public final class ScriptSlot { + + private final String name; + private final LuaSandbox sandbox; + private final Map store = new HashMap<>(); + private final LuaTable registry = buildRegistry(); + private LuaValue chunk; + private String error; + + public ScriptSlot(String name, String source, LuaSandbox sandbox) { + this.name = name; + this.sandbox = sandbox; + try { + this.chunk = sandbox.globals().load(LuaPreprocessor.process(source), name); + } catch (LuaError e) { + this.error = e.getMessage(); + } + } + + public String name() { + return name; + } + + public String error() { + return error; + } + + public boolean canRun() { + return chunk != null && error == null; + } + + /** Таблица {@code registry} для глобалов скрипта: getOrDefault/put. */ + public LuaTable registryTable() { + return registry; + } + + private LuaTable buildRegistry() { + LuaTable t = new LuaTable(); + t.set("getOrDefault", new VarArgFunction() { + @Override + public Varargs invoke(Varargs a) { + LuaValue v = store.get(a.arg(2).tojstring()); + return v != null ? v : a.arg(3); + } + }); + t.set("put", new VarArgFunction() { + @Override + public Varargs invoke(Varargs a) { + store.put(a.arg(2).tojstring(), a.arg(3)); + return NONE; + } + }); + return t; + } + + /** Запускает чанк; ошибка Lua блокирует скрипт и сохраняется в {@link #error()}. */ + public void run() { + if (!canRun()) return; + sandbox.beginRun(); + try { + chunk.call(); + } catch (LuaError e) { + error = e.getMessage(); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/JumpCircles.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/JumpCircles.java index a5fd3bf..77ca266 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/JumpCircles.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/JumpCircles.java @@ -1,60 +1,69 @@ package dev.loki.lovisual.features.module.modules.visuals.player; -import com.mojang.blaze3d.pipeline.RenderPipeline; -import net.minecraft.client.Minecraft; -import net.minecraft.resources.Identifier; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.phys.Vec3; import dev.loki.lovisual.config.common.CommonSettingSchemas; -import dev.loki.lovisual.config.values.primitive.BooleanValue; -import dev.loki.lovisual.config.values.mode.EnumValue; -import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.config.values.color.RGBAColorValue; +import dev.loki.lovisual.config.values.mode.EnumValue; +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.modules.visuals.player.jumpcircles.CircleAnimation; +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.CircleColorMode; +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.CircleRenderer; +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.JumpCircle; +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.Shape; import dev.loki.lovisual.features.module.phase.WorldPhase; -import dev.loki.lovisual.render.engine.texture.TextureStorage; import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; -import dev.loki.lovisual.render.engine.color.RenderColor; -import dev.loki.lovisual.render.engine.math.ColorMath; -import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.texture.TextureStorage; import dev.loki.lovisual.render.engine.uniform.MeshBuilder; -import dev.loki.lovisual.util.time.Timer; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Player; -import java.util.*; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.IntUnaryOperator; -//todo Description +/** Draws an animated shape on the ground where a player left it by jumping. */ @ModuleInfo( id = "jumpcircles", displayName = "JumpCircles", category = ModuleCategory.VISUALS ) public class JumpCircles extends Module { + private static final double SURFACE_LIFT = 0.02; + private final EnumValue mode = - enumSetting("jumpCirclesMode", "mode", CircleMode.DEFAULT); - private final BooleanValue easeOut = bool("jumpCirclesEaseOut", "ease_out", true); - private final NumberValue rotateSpeed = num("jumpCirclesRotateSpeed", "rotate_speed", 2f, 0.5f, 5f); - private final NumberValue circleScale = common( - num("jumpCirclesCircleScale", "circle_scale", 1f, 0.5f, 5f), + enumSetting("jumpCirclesMode", "mode", CircleMode.CIRCLE); + private final NumberValue radius = common( + num("jumpCirclesCircleScale", "circle_scale", 1.5f, 0.5f, 5f), CommonSettingSchemas.RENDER_SCALE.commonI18nKey() ); - private final NumberValue pulseMaxSize = visibleWhen(num( - "jumpCirclesPulseMaxSize", "pulse_max_size", 2.5f, 1.0f, 3.0f - ), this::isPulseMode); - private final NumberValue pulseLifetime = visibleWhen(num( - "jumpCirclesPulseLifetime", "pulse_lifetime", 1000, 500, 5000 - ), this::isPulseMode); + private final NumberValue speed = num("jumpCirclesSpeed", "speed", 1f, 0.5f, 3f); + private final NumberValue fadeSpeed = num("jumpCirclesFadeSpeed", "fade_speed", 1f, 0.5f, 3f); + private final NumberValue rotateSpeed = num("jumpCirclesRotateSpeed", "rotate_speed", 1f, 0f, 5f); + private final NumberValue thickness = visibleWhen( + num("jumpCirclesThickness", "thickness", 0.25f, 0.08f, 0.5f), this::usesThickness); + private final NumberValue segments = visibleWhen( + num("jumpCirclesSegments", "segments", 32, 12, 96), this::usesSegments); + private final NumberValue maxCircles = num("jumpCirclesMax", "max_circles", 12, 1, 32); private final BooleanValue onlySelf = bool("jumpCirclesOnlySelf", "only_self", false); private final BooleanValue depthTest = common( bool("jumpCirclesDepthTest", "depth_test", true), CommonSettingSchemas.RENDER_DEPTH_TEST.commonI18nKey() ); - private final EnumValue colorMode = - enumCommon("jumpCirclesColorMode", "color_mode", - CommonSettingSchemas.RENDER_COLOR_MODE.commonI18nKey(), ColorMode.STATIC); + private final EnumValue colorMode = + enumCommon("jumpCirclesCircleColorMode", "color_mode", + CommonSettingSchemas.RENDER_COLOR_MODE.commonI18nKey(), CircleColorMode.STATIC); private final NumberValue colorSpeed = common( num("jumpCirclesColorSpeed", "color_speed", 18, 2, 54), CommonSettingSchemas.RENDER_COLOR_SPEED.commonI18nKey() @@ -66,9 +75,12 @@ public class JumpCircles extends Module { private final RGBAColorValue colorValue2 = visibleWhen(common( color("jumpCirclesColor2", "color2", "#FFFF5555"), CommonSettingSchemas.RENDER_SECONDARY_COLOR.commonI18nKey() - ), this::usesSecondaryColor); - private final List circles = new ArrayList<>(); - private final Map groundState = new HashMap<>(); + ), () -> AnimatedRenderColors.usesSecondary(colorMode.get().animated())); + + private final Deque circles = new ArrayDeque<>(); + private final Map lastGroundY = new HashMap<>(); + private final Set seen = new HashSet<>(); + private final CircleRenderer circleRenderer = new CircleRenderer(); @Override public WorldPhase getWorldPhase() { @@ -80,33 +92,28 @@ public class JumpCircles extends Module { Minecraft mc = Minecraft.getInstance(); if (!isEnabled() || mc.level == null || mc.player == null) return; - boolean selfOnly = onlySelf.get(); - Set active = new HashSet<>(); - + seen.clear(); for (Player pl : mc.level.players()) { - if (selfOnly && pl != mc.player) continue; + if (onlySelf.get() && pl != mc.player) continue; UUID id = pl.getUUID(); - active.add(id); - - boolean prevGround = groundState.getOrDefault(id, false); - boolean nowGround = pl.onGround(); - if (prevGround && !nowGround) { - circles.add(new Circle( - new Vec3(pl.getX(), Math.floor(pl.getY()) + 0.001f, pl.getZ()), - new Timer() - )); + seen.add(id); + if (pl.onGround()) { + lastGroundY.put(id, pl.getY()); + } else { + Double groundY = lastGroundY.remove(id); + if (groundY != null) spawn(pl.getX(), groundY, pl.getZ()); } - groundState.put(id, nowGround); } - - groundState.keySet().removeIf(id -> !active.contains(id)); - circles.removeIf(c -> c.timer.passedMs(lifetimeMs())); + lastGroundY.keySet().retainAll(seen); + long now = GameClock.millis(); + float lifetime = CircleAnimation.lifetimeMs(speed.get()); + circles.removeIf(c -> now - c.spawnMs() >= lifetime); } @Override public void onDisable() { circles.clear(); - groundState.clear(); + lastGroundY.clear(); } @Override @@ -114,185 +121,78 @@ public class JumpCircles extends Module { Minecraft mc = Minecraft.getInstance(); if (!isEnabled() || mc.level == null || mc.player == null || circles.isEmpty()) return; - boolean useDepth = depthTest.get(); - RenderPipeline pipeline = useDepth - ? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE - : LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE; - Renderer3D.DepthMode depthMode = useDepth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN; - MeshBuilder mesh = renderer.batchTextured(pipeline, resolveTexture(), depthMode); + CircleMode circleMode = mode.get(); + Shape shape = circleMode.shape; + MeshBuilder mesh = circleRenderer.openMesh(renderer, shape, circleMode.texture, depthTest.get()); if (mesh == null) return; + float nowMs = GameClock.millis() + tickDelta * GameClock.TICK_MS; + float lifetime = CircleAnimation.lifetimeMs(speed.get()); + IntUnaryOperator palette = this::colorAt; + for (JumpCircle c : circles) { + float age = Math.max(0f, nowMs - c.spawnMs()); + float progress = CircleAnimation.progress(age, lifetime); + float alpha = CircleAnimation.alpha(progress, fadeSpeed.get()); + if (progress >= 1f || alpha <= 0f) continue; - for (int i = circles.size() - 1; i >= 0; i--) { - Circle c = circles.get(i); - if (isPulseMode()) { - renderPulseCircle(mesh, c); + CircleRenderer.Pose pose = new CircleRenderer.Pose(c.x(), c.y(), c.z(), + CircleAnimation.radius(progress, radius.get()), + CircleAnimation.angle(age / 1000f, rotateSpeed.get(), progress), + alpha, CircleAnimation.wavePhase(age / 1000f)); + if (shape == null) { + circleRenderer.texturedQuad(mesh, pose, palette); } else { - renderLegacyCircle(mesh, c); + circleRenderer.shape(mesh, pose, shape, segments.get(), thickness.get(), palette); } } } - private void renderLegacyCircle(MeshBuilder mesh, Circle c) { - float colorAnim = c.timer.getPassedTimeMs() / 6000f; - float t = (c.timer.getPassedTimeMs() * (easeOut.get() ? 2f : 1f)) / 5000f; - float sizeAnim = circleScale.get() - (float) Math.pow(1f - t, 4f); - if (sizeAnim <= 0f) return; - - float angleDeg = sizeAnim * rotateSpeed.get() * 1000f; - emitCircleQuad(mesh, c.pos, sizeAnim, angleDeg, 1f - colorAnim); + private void spawn(double x, double groundY, double z) { + circles.addLast(new JumpCircle(x, groundY + SURFACE_LIFT, z, GameClock.millis())); + while (circles.size() > maxCircles.get()) { + circles.removeFirst(); + } } - private void renderPulseCircle(MeshBuilder mesh, Circle c) { - float progress = Math.min(c.timer.getPassedTimeMs() / (float) pulseLifetime.get(), 1f); - if (progress >= 1f) return; - - float scale = bounceOut(progress) * pulseMaxSize.get() * circleScale.get(); - if (scale <= 0f) return; - - float alpha = pulseAlpha(progress); - emitCircleQuad(mesh, c.pos, scale * 0.5f, 0f, alpha); + private boolean usesThickness() { + return mode.get().shape != null && mode.get().shape.usesThickness(); } - private void emitCircleQuad(MeshBuilder mesh, Vec3 center, float half, float angleDeg, float alpha) { - double radians = Math.toRadians(angleDeg); - float cos = (float) Math.cos(radians); - float sin = (float) Math.sin(radians); - - Vec3 right = new Vec3(cos, 0.0, -sin); - Vec3 forward = new Vec3(sin, 0.0, cos); - - int c1 = ColorMath.scaleAlpha(getColorArgb(270), alpha); - int c2 = ColorMath.scaleAlpha(getColorArgb(0), alpha); - int c3 = ColorMath.scaleAlpha(getColorArgb(180), alpha); - int c4 = ColorMath.scaleAlpha(getColorArgb(90), alpha); - - Vec3 p1 = center.add(right.scale(-half)).add(forward.scale(half)); - Vec3 p2 = center.add(right.scale(half)).add(forward.scale(half)); - Vec3 p3 = center.add(right.scale(half)).add(forward.scale(-half)); - Vec3 p4 = center.add(right.scale(-half)).add(forward.scale(-half)); - - mesh.ensureQuadCapacity(); - int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(new RenderColor(c1)).next(); - int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(new RenderColor(c2)).next(); - int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(new RenderColor(c3)).next(); - int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(new RenderColor(c4)).next(); - mesh.quad(i1, i2, i3, i4); + private boolean usesSegments() { + return mode.get().shape != null && mode.get().shape.usesSegments(); } - private boolean usesSecondaryColor() { - return AnimatedRenderColors.usesSecondary(animatedColorMode()); - } - - private boolean isPulseMode() { - return mode.get() == CircleMode.PULSE; - } - - private long lifetimeMs() { - return isPulseMode() ? pulseLifetime.get() : (easeOut.get() ? 5000L : 6000L); - } - - private int getColorArgb(int count) { + private int colorAt(int phase) { return AnimatedRenderColors.resolve( - animatedColorMode(), - colorSpeed.get(), - count, - colorValue.getArgb(), - colorValue2.getArgb() - ); + colorMode.get().animated(), colorSpeed.get(), phase, colorValue.getArgb(), colorValue2.getArgb()); } - private AnimatedRenderColors.Mode animatedColorMode() { - return switch (colorMode.get()) { - case RAINBOW -> AnimatedRenderColors.Mode.RAINBOW; - case LIGHT_RAINBOW -> AnimatedRenderColors.Mode.LIGHT_RAINBOW; - case SKY -> AnimatedRenderColors.Mode.SKY; - case FADE -> AnimatedRenderColors.Mode.FADE; - case DOUBLE_COLOR -> AnimatedRenderColors.Mode.DOUBLE_COLOR; - case ANALOGOUS -> AnimatedRenderColors.Mode.ANALOGOUS; - case THEME -> AnimatedRenderColors.Mode.THEME; - case STATIC -> AnimatedRenderColors.Mode.STATIC; - }; - } + private enum CircleMode implements EnumValue.AliasProvider { + CIRCLE(TextureStorage.DEFAULT_CIRCLE, null), + RING(TextureStorage.JUMP_CIRCLE, null), + BUBBLE(TextureStorage.BUBBLE, null), + STAR(null, Shape.STAR), + DIAMOND(null, Shape.DIAMOND), + SPIRAL(null, Shape.SPIRAL), + HEXAGON(null, Shape.HEXAGON), + WAVE(null, Shape.WAVE), + CROSS(null, Shape.CROSS), + PETALS(null, Shape.PETALS); - private Identifier resolveTexture() { - return switch (mode.get()) { - case BUBBLE -> TextureStorage.BUBBLE; - case PULSE -> TextureStorage.JUMP_CIRCLE; - case DEFAULT -> TextureStorage.DEFAULT_CIRCLE; - }; - } + private final Identifier texture; + private final Shape shape; - private float pulseAlpha(float progress) { - final float fadeInDuration = 0.15f; - final float glowStart = 0.65f; - final float fadeOutStart = 0.85f; - float alpha; - - if (progress < fadeInDuration) { - alpha = progress / fadeInDuration; - } else if (progress >= fadeOutStart) { - float fadeOutProgress = (progress - fadeOutStart) / (1f - fadeOutStart); - alpha = 1f - fadeOutProgress; - if (progress > glowStart) { - float glowProgress = (progress - glowStart) / (fadeOutStart - glowStart); - float glowPulse = (float) (Math.sin(glowProgress * Math.PI * 3) * 0.3 + 0.3); - alpha += glowPulse * (1f - fadeOutProgress); - } - } else if (progress > glowStart) { - float glowProgress = (progress - glowStart) / (fadeOutStart - glowStart); - float glowPulse = (float) (Math.sin(glowProgress * Math.PI * 3) * 0.3 + 0.3); - alpha = 1f + glowPulse; - } else { - alpha = 1f; + CircleMode(Identifier texture, Shape shape) { + this.texture = texture; + this.shape = shape; } - return Math.max(0f, Math.min(1f, alpha)); - } - - private float bounceOut(float value) { - float n1 = 7.5625f; - float d1 = 2.75f; - if (value < 1.0f / d1) { - return n1 * value * value; - } else if (value < 2.0f / d1) { - value -= 1.5f / d1; - return n1 * value * value + 0.75f; - } else if (value < 2.5f / d1) { - value -= 2.25f / d1; - return n1 * value * value + 0.9375f; - } else { - value -= 2.625f / d1; - return n1 * value * value + 0.984375f; - } - } - - private enum CircleMode { - DEFAULT, - BUBBLE, - PULSE - } - - private enum ColorMode implements EnumValue.AliasProvider { - STATIC, - RAINBOW, - LIGHT_RAINBOW, - SKY, - FADE, - DOUBLE_COLOR, - ANALOGOUS, - THEME; - @Override public List aliases() { return switch (this) { - case LIGHT_RAINBOW -> List.of("LightRainbow"); - case DOUBLE_COLOR -> List.of("DoubleColor"); - case THEME -> List.of("Theme"); + case CIRCLE -> List.of("Default"); + case RING -> List.of("Pulse"); default -> List.of(); }; } } - - private record Circle(Vec3 pos, Timer timer) { - } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimation.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimation.java new file mode 100644 index 0000000..965df02 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimation.java @@ -0,0 +1,56 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles; + +/** Pure timing and easing maths for a single jump circle; no Minecraft dependencies. */ +public final class CircleAnimation { + public static final float BASE_LIFETIME_MS = 2400f; + private static final float TWO_PI = (float) (Math.PI * 2.0); + private static final float FADE_IN = 0.08f; + private static final float SCALE_PULSE = 0.04f; + private static final float ALPHA_PULSE = 0.15f; + private static final float SWAY = 0.15f; + private static final float SPIN_PER_SECOND = 0.8f; + private static final float WAVE_RADIANS_PER_SECOND = 3f; + + private CircleAnimation() { + } + + public static float lifetimeMs(float speed) { + return BASE_LIFETIME_MS / Math.max(0.1f, speed); + } + + public static float progress(float ageMs, float lifetimeMs) { + return clamp01(ageMs / lifetimeMs); + } + + public static float easeOutCubic(float p) { + float inv = 1f - clamp01(p); + return 1f - inv * inv * inv; + } + + /** Eased growth to {@code maxRadius} with a slight breathing; never exceeds {@code maxRadius}. */ + public static float radius(float progress, float maxRadius) { + float breathe = 0.5f + 0.5f * (float) Math.sin(progress * TWO_PI * 4f); + return maxRadius * easeOutCubic(progress) * (1f - SCALE_PULSE + SCALE_PULSE * breathe); + } + + /** Short fade-in, then a fade-out whose steepness grows with {@code fadeSpeed}; result is in [0,1]. */ + public static float alpha(float progress, float fadeSpeed) { + float in = clamp01(progress / FADE_IN); + float out = clamp01((1f - progress) * Math.max(0f, fadeSpeed)); + float flicker = 1f - ALPHA_PULSE + ALPHA_PULSE * (0.5f + 0.5f * (float) Math.sin(progress * TWO_PI * 5f)); + return clamp01(in * out * flicker); + } + + /** Steady spin over time plus a small sway driven by progress, in radians. */ + public static float angle(float ageSeconds, float rotateSpeed, float progress) { + return ageSeconds * rotateSpeed * SPIN_PER_SECOND + SWAY * (float) Math.sin(progress * TWO_PI * 2f); + } + + public static float wavePhase(float ageSeconds) { + return ageSeconds * WAVE_RADIANS_PER_SECOND; + } + + private static float clamp01(float v) { + return Math.max(0f, Math.min(1f, v)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleColorMode.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleColorMode.java new file mode 100644 index 0000000..ca2c52f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleColorMode.java @@ -0,0 +1,38 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles; + +import dev.loki.lovisual.config.values.mode.EnumValue; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; + +import java.util.List; + +/** Colour source options for JumpCircles, mapped onto the shared animated render colours. */ +public enum CircleColorMode implements EnumValue.AliasProvider { + STATIC(AnimatedRenderColors.Mode.STATIC), + RAINBOW(AnimatedRenderColors.Mode.RAINBOW), + LIGHT_RAINBOW(AnimatedRenderColors.Mode.LIGHT_RAINBOW), + SKY(AnimatedRenderColors.Mode.SKY), + FADE(AnimatedRenderColors.Mode.FADE), + DOUBLE_COLOR(AnimatedRenderColors.Mode.DOUBLE_COLOR), + ANALOGOUS(AnimatedRenderColors.Mode.ANALOGOUS), + THEME(AnimatedRenderColors.Mode.THEME); + + private final AnimatedRenderColors.Mode animated; + + CircleColorMode(AnimatedRenderColors.Mode animated) { + this.animated = animated; + } + + public AnimatedRenderColors.Mode animated() { + return animated; + } + + @Override + public List aliases() { + return switch (this) { + case LIGHT_RAINBOW -> List.of("LightRainbow"); + case DOUBLE_COLOR -> List.of("DoubleColor"); + case THEME -> List.of("Theme"); + default -> List.of(); + }; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleRenderer.java new file mode 100644 index 0000000..16a1492 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleRenderer.java @@ -0,0 +1,88 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles; + +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.Shape; +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.ShapeGeometry; +import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.ShapeMesh; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import net.minecraft.resources.Identifier; + +import java.util.function.IntUnaryOperator; + +/** Writes jump circles into world meshes: coloured shape triangles or one textured quad. */ +public final class CircleRenderer { + private static final float OUTLINE_BRIGHTNESS = 0.35f; + private final ShapeMesh shapeMesh = new ShapeMesh(); + + /** Where and how a circle is drawn this frame; angle and wavePhase are in radians. */ + public record Pose(double x, double y, double z, float radius, float angle, float alpha, float wavePhase) { + } + + /** Additive batch for shapes (coloured) or for the texture when {@code shape} is null; depth mask is never written. */ + public MeshBuilder openMesh(Renderer3D renderer, Shape shape, Identifier texture, boolean depthTest) { + Renderer3D.DepthMode depthMode = depthTest ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN; + if (shape != null) { + RenderPipeline pipeline = depthTest + ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH + : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE; + return renderer.batch(pipeline, depthMode); + } + RenderPipeline pipeline = depthTest + ? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE + : LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE; + return renderer.batchTextured(pipeline, texture, depthMode); + } + + /** {@code palette} maps a 0..360 gradient phase to ARGB. */ + public void shape(MeshBuilder mesh, Pose pose, Shape shape, int segments, float thickness, + IntUnaryOperator palette) { + ShapeGeometry.build(shape, shapeMesh, segments, thickness, pose.wavePhase()); + int vertices = shapeMesh.vertexCount(); + int indices = shapeMesh.indexCount(); + mesh.ensureCapacity(vertices, indices); + float cos = (float) Math.cos(pose.angle()); + float sin = (float) Math.sin(pose.angle()); + int base = -1; + for (int i = 0; i < vertices; i++) { + float lx = shapeMesh.x(i) * pose.radius(); + float lz = shapeMesh.z(i) * pose.radius(); + int main = palette.applyAsInt(Math.round(shapeMesh.gradient(i) * 360f)); + int argb = AnimatedRenderColors.mixArgb(main, + AnimatedRenderColors.brighten(main, OUTLINE_BRIGHTNESS), shapeMesh.shade(i)); + int v = put(mesh, pose.x() + lx * cos - lz * sin, pose.y(), pose.z() + lx * sin + lz * cos, + AnimatedRenderColors.scaleAlpha(argb, pose.alpha()), false, 0, 0); + if (base < 0) base = v; + } + for (int i = 0; i < indices; i += 3) { + mesh.triangle(base + shapeMesh.index(i), base + shapeMesh.index(i + 1), base + shapeMesh.index(i + 2)); + } + } + + public void texturedQuad(MeshBuilder mesh, Pose pose, IntUnaryOperator palette) { + float r = pose.radius(); + float cos = (float) Math.cos(pose.angle()) * r; + float sin = (float) Math.sin(pose.angle()) * r; + mesh.ensureQuadCapacity(); + int a = corner(mesh, pose, -cos - sin, cos - sin, 0, 1, palette.applyAsInt(270)); + int b = corner(mesh, pose, cos - sin, cos + sin, 1, 1, palette.applyAsInt(0)); + int c = corner(mesh, pose, cos + sin, sin - cos, 1, 0, palette.applyAsInt(180)); + int d = corner(mesh, pose, -cos + sin, -sin - cos, 0, 0, palette.applyAsInt(90)); + mesh.quad(a, b, c, d); + } + + private static int corner(MeshBuilder mesh, Pose pose, double dx, double dz, double u, double v, int argb) { + return put(mesh, pose.x() + dx, pose.y(), pose.z() + dz, + AnimatedRenderColors.scaleAlpha(argb, pose.alpha()), true, u, v); + } + + private static int put(MeshBuilder mesh, double x, double y, double z, int argb, + boolean textured, double u, double v) { + mesh.vec3(x, y, z); + if (textured) mesh.vec2(u, v); + mesh.color((argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF, argb >>> 24); + return mesh.next(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/JumpCircle.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/JumpCircle.java new file mode 100644 index 0000000..031ac15 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/JumpCircle.java @@ -0,0 +1,5 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles; + +/** A circle born at the take-off point; {@code spawnMs} is GameClock time. */ +public record JumpCircle(double x, double y, double z, long spawnMs) { +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/Shape.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/Shape.java new file mode 100644 index 0000000..f9b909f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/Shape.java @@ -0,0 +1,28 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape; + +/** Ground-plane shapes JumpCircles can draw; flags tell which tuning parameters a shape honours. */ +public enum Shape { + STAR(false, false), + DIAMOND(false, false), + SPIRAL(true, true), + HEXAGON(true, false), + WAVE(true, true), + CROSS(false, false), + PETALS(false, true); + + private final boolean usesThickness; + private final boolean usesSegments; + + Shape(boolean usesThickness, boolean usesSegments) { + this.usesThickness = usesThickness; + this.usesSegments = usesSegments; + } + + public boolean usesThickness() { + return usesThickness; + } + + public boolean usesSegments() { + return usesSegments; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometry.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometry.java new file mode 100644 index 0000000..c35353c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometry.java @@ -0,0 +1,176 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape; + +/** Pure geometry for the JumpCircles shapes; every vertex stays inside the unit circle. */ +public final class ShapeGeometry { + public static final float EDGE_SHADE = 0.55f; + private static final float TWO_PI = (float) (Math.PI * 2.0); + private static final int STAR_POINTS = 5; + private static final float STAR_INNER = 0.45f; + private static final float DIAMOND_WIDTH = 0.7f; + private static final int SPIRAL_ARMS = 3; + private static final int HEX_SIDES = 6; + private static final float WAVE_AMPLITUDE = 0.08f; + private static final float WAVE_COUNT = 6.0f; + private static final float CROSS_CORE = 0.12f; + private static final int PETAL_COUNT = 6; + private static final float PETAL_CENTER = 0.55f; + private static final float PETAL_LENGTH = 0.45f; + private static final float PETAL_WIDTH = 0.2f; + + private ShapeGeometry() { + } + + /** + * Rebuilds {@code out} for the given shape. + * + * @param segments detail per arm, petal or ring (used by SPIRAL, WAVE, PETALS) + * @param thickness ring band width in unit radius (used by SPIRAL, HEXAGON, WAVE) + * @param phase wave travel phase in radians (used by WAVE) + */ + public static void build(Shape shape, ShapeMesh out, int segments, float thickness, float phase) { + out.clear(); + int seg = Math.max(3, segments); + float band = Math.max(0.02f, Math.min(0.5f, thickness)); + switch (shape) { + case STAR -> star(out); + case DIAMOND -> diamond(out); + case SPIRAL -> spiral(out, seg, band); + case HEXAGON -> hexagon(out, band); + case WAVE -> wave(out, Math.max(8, seg), band, phase); + case CROSS -> cross(out); + case PETALS -> petals(out, seg); + } + } + + private static void star(ShapeMesh m) { + int center = m.vertex(0, 0, 0.5f, 0); + int n = STAR_POINTS * 2; + int prev = -1; + for (int i = 0; i <= n; i++) { + int k = i % n; + boolean tip = (k & 1) == 0; + float a = TWO_PI * k / n - (float) Math.PI / 2f; + float r = tip ? 1f : STAR_INNER; + int v = m.vertex((float) Math.cos(a) * r, (float) Math.sin(a) * r, + seamless((float) k / n), tip ? EDGE_SHADE : 0.2f); + if (prev >= 0) m.triangle(center, prev, v); + prev = v; + } + } + + private static void diamond(ShapeMesh m) { + int center = m.vertex(0, 0, 0.5f, 0); + int north = m.vertex(0, -1, 0f, EDGE_SHADE); + int east = m.vertex(DIAMOND_WIDTH, 0, 0.5f, EDGE_SHADE); + int south = m.vertex(0, 1, 1f, EDGE_SHADE); + int west = m.vertex(-DIAMOND_WIDTH, 0, 0.5f, EDGE_SHADE); + m.triangle(center, north, east); + m.triangle(center, east, south); + m.triangle(center, south, west); + m.triangle(center, west, north); + } + + private static void spiral(ShapeMesh m, int segments, float band) { + for (int arm = 0; arm < SPIRAL_ARMS; arm++) { + float start = TWO_PI * arm / SPIRAL_ARMS; + int prevIn = -1; + int prevOut = -1; + for (int k = 0; k <= segments; k++) { + float u = (float) k / segments; + float a = start + u * TWO_PI * 0.3f; + float center = (1f - band * 0.5f) * (0.5f + 0.5f * u); + float half = band * 0.5f * (float) Math.sin(Math.PI * u); + float grad = seamless((arm + u) / SPIRAL_ARMS); + int in = polar(m, a, center - half, grad, 0f); + int out = polar(m, a, center + half, grad, EDGE_SHADE); + strip(m, prevIn, prevOut, in, out); + prevIn = in; + prevOut = out; + } + } + } + + private static void hexagon(ShapeMesh m, float band) { + for (int i = 0; i < HEX_SIDES; i++) { + float a0 = TWO_PI * i / HEX_SIDES; + float a1 = TWO_PI * (i + 1) / HEX_SIDES; + float shade = (i & 1) == 0 ? 0f : EDGE_SHADE; + float grad = seamless((float) i / HEX_SIDES); + int in0 = polar(m, a0, 1f - band, grad, shade); + int out0 = polar(m, a0, 1f, grad, shade); + int in1 = polar(m, a1, 1f - band, grad, shade); + int out1 = polar(m, a1, 1f, grad, shade); + strip(m, in0, out0, in1, out1); + } + } + + private static void wave(ShapeMesh m, int segments, float band, float phase) { + int prevIn = -1; + int prevOut = -1; + int firstIn = -1; + int firstOut = -1; + for (int k = 0; k < segments; k++) { + float a = TWO_PI * k / segments; + float outer = 1f - WAVE_AMPLITUDE + WAVE_AMPLITUDE * (float) Math.sin(WAVE_COUNT * a + phase); + float grad = seamless((float) k / segments); + int in = polar(m, a, Math.max(0f, outer - band), grad, 0f); + int out = polar(m, a, outer, grad, EDGE_SHADE); + if (firstIn < 0) { + firstIn = in; + firstOut = out; + } + strip(m, prevIn, prevOut, in, out); + prevIn = in; + prevOut = out; + } + strip(m, prevIn, prevOut, firstIn, firstOut); + } + + private static void cross(ShapeMesh m) { + int c0 = m.vertex(CROSS_CORE, -CROSS_CORE, 0.5f, 0f); + int c1 = m.vertex(CROSS_CORE, CROSS_CORE, 0.5f, 0f); + int c2 = m.vertex(-CROSS_CORE, CROSS_CORE, 0.5f, 0f); + int c3 = m.vertex(-CROSS_CORE, -CROSS_CORE, 0.5f, 0f); + m.triangle(c0, c1, c2); + m.triangle(c0, c2, c3); + m.triangle(c0, c1, m.vertex(1f, 0f, 0f, EDGE_SHADE)); + m.triangle(c1, c2, m.vertex(0f, 1f, 0.5f, EDGE_SHADE)); + m.triangle(c2, c3, m.vertex(-1f, 0f, 1f, EDGE_SHADE)); + m.triangle(c3, c0, m.vertex(0f, -1f, 0.5f, EDGE_SHADE)); + } + + private static void petals(ShapeMesh m, int segments) { + for (int p = 0; p < PETAL_COUNT; p++) { + float a = TWO_PI * p / PETAL_COUNT; + float cos = (float) Math.cos(a); + float sin = (float) Math.sin(a); + float grad = seamless((float) p / PETAL_COUNT); + int center = m.vertex(cos * PETAL_CENTER, sin * PETAL_CENTER, grad, 0f); + int prev = -1; + for (int k = 0; k <= segments; k++) { + float u = TWO_PI * k / segments; + float along = PETAL_CENTER + PETAL_LENGTH * (float) Math.cos(u); + float side = PETAL_WIDTH * (float) Math.sin(u); + int v = m.vertex(cos * along - sin * side, sin * along + cos * side, grad, EDGE_SHADE); + if (prev >= 0) m.triangle(center, prev, v); + prev = v; + } + } + } + + private static int polar(ShapeMesh m, float angle, float radius, float gradient, float shade) { + return m.vertex((float) Math.cos(angle) * radius, (float) Math.sin(angle) * radius, gradient, shade); + } + + /** Joins two consecutive inner/outer vertex pairs of a band; the first pair has index -1 and is skipped. */ + private static void strip(ShapeMesh m, int in0, int out0, int in1, int out1) { + if (in0 < 0) return; + m.triangle(in0, out0, out1); + m.triangle(in0, out1, in1); + } + + /** Maps [0,1] to a there-and-back ramp so a gradient wraps around a closed ring without a seam. */ + private static float seamless(float f) { + return 1f - Math.abs(2f * f - 1f); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeMesh.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeMesh.java new file mode 100644 index 0000000..3ec62d0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeMesh.java @@ -0,0 +1,74 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape; + +import java.util.Arrays; + +/** + * Reusable triangle list in the unit ground plane. Each vertex carries a gradient position + * {@code t} in [0,1] and a {@code shade} in [0,1] (0 = main colour, 1 = darkened outline colour). + */ +public final class ShapeMesh { + private float[] x = new float[64]; + private float[] z = new float[64]; + private float[] t = new float[64]; + private float[] shade = new float[64]; + private int[] indices = new int[192]; + private int vertexCount; + private int indexCount; + + public void clear() { + vertexCount = 0; + indexCount = 0; + } + + public int vertex(float px, float pz, float gradient, float vertexShade) { + if (vertexCount == x.length) { + int size = vertexCount * 2; + x = Arrays.copyOf(x, size); + z = Arrays.copyOf(z, size); + t = Arrays.copyOf(t, size); + shade = Arrays.copyOf(shade, size); + } + x[vertexCount] = px; + z[vertexCount] = pz; + t[vertexCount] = gradient; + shade[vertexCount] = vertexShade; + return vertexCount++; + } + + public void triangle(int a, int b, int c) { + if (indexCount + 3 > indices.length) { + indices = Arrays.copyOf(indices, indices.length * 2); + } + indices[indexCount++] = a; + indices[indexCount++] = b; + indices[indexCount++] = c; + } + + public int vertexCount() { + return vertexCount; + } + + public int indexCount() { + return indexCount; + } + + public float x(int i) { + return x[i]; + } + + public float z(int i) { + return z[i]; + } + + public float gradient(int i) { + return t[i]; + } + + public float shade(int i) { + return shade[i]; + } + + public int index(int i) { + return indices[i]; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java index 30eea64..9dfa940 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java @@ -42,7 +42,7 @@ public final class ServerPolicyClient { public static void init() { PayloadTypeRegistry.serverboundPlay().register(Payload.TYPE, Payload.CODEC); PayloadTypeRegistry.clientboundPlay().register(Payload.TYPE, Payload.CODEC); - ClientPlayNetworking.registerReceiver(Payload.TYPE, + ClientPlayNetworking.registerGlobalReceiver(Payload.TYPE, (payload, context) -> onPolicy(payload.json())); ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> ClientPlayNetworking.send(new Payload(ServerPolicyCodec.REQUEST_JSON))); diff --git a/mod/src/main/java/dev/loki/lovisual/features/theme/ThemeStore.java b/mod/src/main/java/dev/loki/lovisual/features/theme/ThemeStore.java index ab9e808..6580445 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/theme/ThemeStore.java +++ b/mod/src/main/java/dev/loki/lovisual/features/theme/ThemeStore.java @@ -24,7 +24,7 @@ public final class ThemeStore { final List themes = new ArrayList<>(); final List customThemes = new ArrayList<>(); final Map themesById = new LinkedHashMap<>(); - String currentId = Themes.THEME_CLASSIC; + String currentId = Themes.THEME_DEFAULT; private ThemeStore() { ThemeStoreIO.load(this); diff --git a/mod/src/main/java/dev/loki/lovisual/features/theme/Themes.java b/mod/src/main/java/dev/loki/lovisual/features/theme/Themes.java index e3c66b3..75d5a1e 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/theme/Themes.java +++ b/mod/src/main/java/dev/loki/lovisual/features/theme/Themes.java @@ -35,10 +35,10 @@ public enum Themes { public static final String THEME_PRISM = "prism"; public static final String THEME_CINDER = "cinder"; public static final String THEME_FOREST = "forest"; + /** Theme used on a fresh install and whenever the saved id is missing. */ + public static final String THEME_DEFAULT = THEME_AMBER; static final long THEME_TRANSITION_MS = 340L; - static final ThemeStore STORE = ThemeStore.get(); - /** Display order for the preset picker; also drives {@link #PRESET_ENTRIES}. */ static final List PRESET_LIST = List.of( ThemePresets1.CLASSIC_ENTRY, ThemePresets1.LEGACY_ENTRY, ThemePresets1.AMBER_ENTRY, @@ -49,6 +49,8 @@ public enum Themes { ThemePresets3.PRISM_ENTRY, ThemePresets3.CINDER_ENTRY, ThemePresets3.FOREST_ENTRY ); static final Map PRESET_ENTRIES = buildPresetMap(PRESET_LIST); + // Must come after the preset tables: loading the store rebuilds its index from presetEntries(). + static final ThemeStore STORE = ThemeStore.get(); private static Map buildPresetMap(List list) { Map m = new LinkedHashMap<>(); @@ -57,15 +59,15 @@ public enum Themes { } // Mutable transition state; read / written by ThemesTransition (same package). - static ThemeEntry currentEntry = ThemePresets1.CLASSIC_ENTRY; - static ThemeEntry transitionFromEntry = ThemePresets1.CLASSIC_ENTRY; - static ThemeEntry animatedEntryCache = ThemePresets1.CLASSIC_ENTRY; - static Theme current = ThemePresets1.CLASSIC; - static String currentId = THEME_CLASSIC; - static Theme transitionFromTheme = ThemePresets1.CLASSIC; + static ThemeEntry currentEntry = ThemePresets1.AMBER_ENTRY; + static ThemeEntry transitionFromEntry = ThemePresets1.AMBER_ENTRY; + static ThemeEntry animatedEntryCache = ThemePresets1.AMBER_ENTRY; + static Theme current = ThemePresets1.AMBER; + static String currentId = THEME_DEFAULT; + static Theme transitionFromTheme = ThemePresets1.AMBER; static long transitionStartMs = 0L; static boolean transitionActive = false; - static Theme animatedThemeCache = ThemePresets1.CLASSIC; + static Theme animatedThemeCache = ThemePresets1.AMBER; static int animatedCacheKey = -1; static { diff --git a/mod/src/main/java/dev/loki/lovisual/features/theme/impl/ThemesTransition.java b/mod/src/main/java/dev/loki/lovisual/features/theme/impl/ThemesTransition.java index d74b14c..f77e62e 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/theme/impl/ThemesTransition.java +++ b/mod/src/main/java/dev/loki/lovisual/features/theme/impl/ThemesTransition.java @@ -33,15 +33,15 @@ final class ThemesTransition { /** Switch to the theme stored under the given preset / custom id, or fall back to classic. */ static void setTheme(String id) { if (id == null || id.isBlank()) { - boolean animate = !Themes.THEME_CLASSIC.equals(Themes.currentId) || Themes.transitionActive; - applyThemeFromEntry(ThemePresets1.CLASSIC_ENTRY, animate); - Themes.STORE.setCurrentId(Themes.THEME_CLASSIC); + boolean animate = !Themes.THEME_DEFAULT.equals(Themes.currentId) || Themes.transitionActive; + applyThemeFromEntry(ThemePresets1.AMBER_ENTRY, animate); + Themes.STORE.setCurrentId(Themes.THEME_DEFAULT); return; } String key = id.toLowerCase(); ThemeEntry entry = Themes.STORE.getById(key); if (entry == null) entry = Themes.PRESET_ENTRIES.get(key); - if (entry == null) entry = ThemePresets1.CLASSIC_ENTRY; + if (entry == null) entry = ThemePresets1.AMBER_ENTRY; boolean animate = !entry.getId().equals(Themes.currentId) || Themes.transitionActive; applyThemeFromEntry(entry, animate); Themes.STORE.setCurrentId(entry.getId()); @@ -78,8 +78,8 @@ final class ThemesTransition { static void applyFromStore() { String id = Themes.STORE.getCurrentId(); ThemeEntry entry = Themes.STORE.getById(id); - if (entry == null) entry = Themes.PRESET_ENTRIES.get(Themes.THEME_CLASSIC); - if (entry == null) entry = ThemePresets1.CLASSIC_ENTRY; + if (entry == null) entry = Themes.PRESET_ENTRIES.get(Themes.THEME_DEFAULT); + if (entry == null) entry = ThemePresets1.AMBER_ENTRY; applyThemeFromEntry(entry, false); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/theme/store/ThemeStoreIO.java b/mod/src/main/java/dev/loki/lovisual/features/theme/store/ThemeStoreIO.java index 84ccc92..ac6e32f 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/theme/store/ThemeStoreIO.java +++ b/mod/src/main/java/dev/loki/lovisual/features/theme/store/ThemeStoreIO.java @@ -38,7 +38,7 @@ final class ThemeStoreIO { retireLegacyFile(store); rebuildMergedIndex(store); if (store.getById(store.currentId) == null) { - store.currentId = Themes.THEME_CLASSIC; + store.currentId = Themes.THEME_DEFAULT; saveState(store); } } @@ -47,7 +47,7 @@ final class ThemeStoreIO { String fromState = readCurrentId(STATE_FILE); if (fromState != null) return fromState; String fromLegacy = readCurrentId(LEGACY_FILE); - return fromLegacy != null ? fromLegacy : Themes.THEME_CLASSIC; + return fromLegacy != null ? fromLegacy : Themes.THEME_DEFAULT; } private static String readCurrentId(Path file) { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiHealthLayoutHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiHealthLayoutHandler.java similarity index 88% rename from mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiHealthLayoutHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiHealthLayoutHandler.java index f94dc35..8f2bf81 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiHealthLayoutHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiHealthLayoutHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.hud; +package dev.loki.lovisual.mixinhandlers.hud; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; @@ -14,11 +14,11 @@ import dev.loki.lovisual.features.gui.hud.nondraggable.impl.bars.CustomHealthBar * vanilla {@code extractPlayerHealth} pass is fed neutral values (absorption 0, max health 20) so it * reserves the correct space without drawing hearts we replace ourselves. */ -final class GuiHealthLayoutHandler { +public final class GuiHealthLayoutHandler { private GuiHealthLayoutHandler() { } - static float ignoreAbsorptionForLayout(Player player) { + public static float ignoreAbsorptionForLayout(Player player) { if (!Lifecycle.canRunHud()) { return player.getAbsorptionAmount(); } @@ -32,7 +32,7 @@ final class GuiHealthLayoutHandler { return player.getAbsorptionAmount(); } - static double clampMaxHealthForLayout(Player player, Holder attribute) { + public static double clampMaxHealthForLayout(Player player, Holder attribute) { if (!Lifecycle.canRunHud()) { return player.getAttributes().getValue(attribute); } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiHudBarHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiHudBarHandler.java similarity index 93% rename from mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiHudBarHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiHudBarHandler.java index ec40efd..6ff975d 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiHudBarHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiHudBarHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.hud; +package dev.loki.lovisual.mixinhandlers.hud; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; @@ -25,14 +25,14 @@ import dev.loki.lovisual.render.engine.renderer.Renderer2D; * health bar. All passes are emitted through a deferred {@link Renderer2D} layer so they land after * vanilla misc overlays, then drop a stratum marker via {@link IGuiGraphics}. */ -final class GuiHudBarHandler { +public final class GuiHudBarHandler { private GuiHudBarHandler() { } - static final Renderer2D.Deferred2DLayer VANILLA_HUD_REPLACEMENT_LAYER = + public static final Renderer2D.Deferred2DLayer VANILLA_HUD_REPLACEMENT_LAYER = Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS; - static void renderHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter, CallbackInfo ci) { + public static void renderHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter, CallbackInfo ci) { Minecraft mc = Minecraft.getInstance(); if (mc == null || mc.player == null) return; @@ -84,7 +84,7 @@ final class GuiHudBarHandler { } } - static void afterVanillaHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) { + public static void afterVanillaHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) { Minecraft mc = Minecraft.getInstance(); if (mc == null || mc.player == null) return; @@ -97,7 +97,7 @@ final class GuiHudBarHandler { renderCustomBarOnly(ctx, tickCounter, mc, bar); } - static void renderHealth(GuiGraphicsExtractor context, Player player, int x, int y, float maxHealth, CallbackInfo ci) { + public static void renderHealth(GuiGraphicsExtractor context, Player player, int x, int y, float maxHealth, CallbackInfo ci) { if (!Lifecycle.canRunHud()) return; if (!(player instanceof LocalPlayer clientPlayer)) return; Minecraft mc = Minecraft.getInstance(); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiOverlayHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiOverlayHandler.java similarity index 83% rename from mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiOverlayHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiOverlayHandler.java index 854f935..5b764ef 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiOverlayHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiOverlayHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.hud; +package dev.loki.lovisual.mixinhandlers.hud; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; @@ -20,18 +20,18 @@ import dev.loki.lovisual.util.text.format.TextJsonUtil; * potion effects, crosshair, vignette and the portal/sleep overlays. Each entry point cancels the * vanilla {@code extract*}/{@code display*} call when a module or custom HUD takes over. */ -final class GuiOverlayHandler { +public final class GuiOverlayHandler { private GuiOverlayHandler() { } - static void hideScoreboardSidebar(CallbackInfo ci) { + public static void hideScoreboardSidebar(CallbackInfo ci) { NoRender noRender = Modules.get(NoRender.class); if ((noRender != null && noRender.hideScoreboard()) || Scoreboard.shouldReplaceVanilla()) { ci.cancel(); } } - static void filterOverlayMessage(Component message, boolean tinted, CallbackInfo ci) { + public static void filterOverlayMessage(Component message, boolean tinted, CallbackInfo ci) { if (DebugLog.serverOnly()) { if (message == null) { // DebugLog.server("HUD overlay message: tinted=%s raw=null", tinted); @@ -51,7 +51,7 @@ final class GuiOverlayHandler { } } - static void hideVanillaPotions(CallbackInfo ci) { + public static void hideVanillaPotions(CallbackInfo ci) { Minecraft mc = Minecraft.getInstance(); if (mc == null || mc.player == null) return; if (!DraggableHudElementRegistry.isEnabled(Potions.class)) return; @@ -59,23 +59,23 @@ final class GuiOverlayHandler { ci.cancel(); } - static void replaceCrosshair(CallbackInfo ci) { + public static void replaceCrosshair(CallbackInfo ci) { Crosshair crosshair = Modules.get(Crosshair.class); if (crosshair != null && crosshair.shouldHideVanilla()) { ci.cancel(); } } - static void killVignette(CallbackInfo ci) { + public static void killVignette(CallbackInfo ci) { // Completely remove vanilla vignette post-processing ci.cancel(); } - static void disablePortalOverlay(CallbackInfo ci) { + public static void disablePortalOverlay(CallbackInfo ci) { ci.cancel(); } - static void disableSleepOverlay(CallbackInfo ci) { + public static void disableSleepOverlay(CallbackInfo ci) { ci.cancel(); } } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiTooltipHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiTooltipHandler.java similarity index 93% rename from mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiTooltipHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiTooltipHandler.java index d4aa784..c9a45da 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/hud/handlers/GuiTooltipHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/hud/GuiTooltipHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.hud; +package dev.loki.lovisual.mixinhandlers.hud; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; @@ -18,11 +18,11 @@ import dev.loki.lovisual.util.item.enchant.TopEnchantUtil; * rarity / illegal / top-enchant derived name color and a fade driven by the highlight timer, * cancelling the vanilla render when the custom SwapTooltip is active. */ -final class GuiTooltipHandler { +public final class GuiTooltipHandler { private GuiTooltipHandler() { } - static void colorizeHeldItem(int toolHighlightTimer, ItemStack lastToolHighlight, Minecraft minecraft, CallbackInfo ci) { + public static void colorizeHeldItem(int toolHighlightTimer, ItemStack lastToolHighlight, Minecraft minecraft, CallbackInfo ci) { if (!Lifecycle.canRunHud()) return; SwapTooltip swapTooltip = SwapTooltip.get(); if (swapTooltip == null || !swapTooltip.isEnabled()) { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerPvpHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java similarity index 92% rename from mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerPvpHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java index 75fe487..ed1ac7b 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerPvpHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.network.game; +package dev.loki.lovisual.mixinhandlers.network; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; @@ -30,7 +30,7 @@ import java.util.regex.Pattern; * Chat, tab-metric and totem-pop packet bodies for {@link ClientPacketListenerMixin}. * Physically under {@code handlers/} but declares the parent package for direct access. */ -final class ClientPacketListenerPvpHandler { +public final class ClientPacketListenerPvpHandler { // "TPS: 20.0" или "TPS: *20.0" private static final Pattern TPS_PATTERN = @@ -42,7 +42,7 @@ final class ClientPacketListenerPvpHandler { private ClientPacketListenerPvpHandler() { } - static void captureTab(ClientboundTabListPacket packet) { + public static void captureTab(ClientboundTabListPacket packet) { String header = packet.header() == null ? "" : packet.header().getString(); String footer = packet.footer() == null ? "" : packet.footer().getString(); ServerDumpUtil.dumpTabText("HEADER", packet.header()); @@ -52,7 +52,7 @@ final class ClientPacketListenerPvpHandler { } } - static void onSystemChat(ClientboundSystemChatPacket packet) { + public static void onSystemChat(ClientboundSystemChatPacket packet) { String msg = packet.content().getString(); // PvP state listeners via events; legacy direct setters disabled. @@ -61,7 +61,7 @@ final class ClientPacketListenerPvpHandler { } } - static void onPlayerChat(ClientboundPlayerChatPacket packet) { + public static void onPlayerChat(ClientboundPlayerChatPacket packet) { String msg = packet == null ? null : packet.body().content(); if (msg == null || msg.isBlank()) return; if (Events.BUS.hasListeners(PvpChatEvent.class)) { @@ -69,7 +69,7 @@ final class ClientPacketListenerPvpHandler { } } - static void parseTabMetrics(Component text) { + public static void parseTabMetrics(Component text) { if (text == null) return; String raw = text.getString(); @@ -92,7 +92,7 @@ final class ClientPacketListenerPvpHandler { } } - static void onTotemStatus(ClientboundEntityEventPacket packet, ClientPacketListener listener) { + public static void onTotemStatus(ClientboundEntityEventPacket packet, ClientPacketListener listener) { if (packet.getEventId() == 35) { // 35 = сработал тотем бессмертия Minecraft client = Minecraft.getInstance(); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerSoundHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerSoundHandler.java similarity index 86% rename from mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerSoundHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerSoundHandler.java index e12f6ea..41029f3 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerSoundHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerSoundHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.network.game; +package dev.loki.lovisual.mixinhandlers.network; import net.minecraft.client.Minecraft; import net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket; @@ -22,12 +22,12 @@ import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectHeuristics; * {@code @Inject}/{@code @Redirect} wiring. Physically under {@code handlers/} but * declares the parent package for direct access. */ -final class ClientPacketListenerSoundHandler { +public final class ClientPacketListenerSoundHandler { private ClientPacketListenerSoundHandler() { } - static void filterSound(ClientboundSoundPacket packet, CallbackInfo ci) { + public static void filterSound(ClientboundSoundPacket packet, CallbackInfo ci) { NoSound module = Modules.get(NoSound.class); if (module == null || !module.isEnabled()) return; if (packet == null || packet.getSound() == null) return; @@ -37,7 +37,7 @@ final class ClientPacketListenerSoundHandler { } } - static void filterSoundFromEntity(ClientboundSoundEntityPacket packet, CallbackInfo ci) { + public static void filterSoundFromEntity(ClientboundSoundEntityPacket packet, CallbackInfo ci) { NoSound module = Modules.get(NoSound.class); if (module == null || !module.isEnabled()) return; if (packet == null || packet.getSound() == null) return; @@ -47,7 +47,7 @@ final class ClientPacketListenerSoundHandler { } } - static void onPlaySound(ClientboundSoundPacket packet) { + public static void onPlaySound(ClientboundSoundPacket packet) { SoundESP esp = Modules.get(SoundESP.class); if (esp == null || !esp.isEnabled()) return; if (packet == null || packet.getSound() == null) return; @@ -55,7 +55,7 @@ final class ClientPacketListenerSoundHandler { esp.handleSound(packet.getSound().value().location(), pos); } - static void onPlaySoundFromEntity(ClientboundSoundEntityPacket packet) { + public static void onPlaySoundFromEntity(ClientboundSoundEntityPacket packet) { SoundESP esp = Modules.get(SoundESP.class); if (esp == null || !esp.isEnabled()) return; if (packet == null || packet.getSound() == null) return; @@ -68,7 +68,7 @@ final class ClientPacketListenerSoundHandler { esp.handleSound(packet.getSound().value().location(), entity.position()); } - static void heuristicParticle(ClientboundLevelParticlesPacket packet) { + public static void heuristicParticle(ClientboundLevelParticlesPacket packet) { var p = packet.getParticle(); // Log raw particles once per hook for diagnostics DebugLog.info("Particle packet type=%s pos=%.2f %.2f %.2f count=%d", @@ -78,7 +78,7 @@ final class ClientPacketListenerSoundHandler { StatusEffectHeuristics.handle(p, packet.getX(), packet.getY(), packet.getZ()); } - static void logStatusEffect(ClientboundUpdateMobEffectPacket packet) { + public static void logStatusEffect(ClientboundUpdateMobEffectPacket packet) { DebugLog.server("Packet: add/refresh effect eid=%d effect=%s amp=%d dur=%d particles=%s icon=%s ambient=%s keepFading=%s", packet.getEntityId(), packet.getEffect().unwrapKey().map(k -> k.identifier().toString()).orElse("unknown"), @@ -91,7 +91,7 @@ final class ClientPacketListenerSoundHandler { ); } - static void logRemove(ClientboundRemoveMobEffectPacket packet) { + public static void logRemove(ClientboundRemoveMobEffectPacket packet) { DebugLog.server("Packet: remove effect eid=%d (%s) effect=%s", packet.entityId(), resolveName(packet.entityId()), diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerStaffHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerStaffHandler.java similarity index 88% rename from mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerStaffHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerStaffHandler.java index 7420708..cd4a4c3 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/handlers/ClientPacketListenerStaffHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerStaffHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.network.game; +package dev.loki.lovisual.mixinhandlers.network; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; @@ -19,12 +19,12 @@ import java.util.UUID; * staff tracker and totem-pop counter from spawn/despawn and player-info updates. * Physically under {@code handlers/} but declares the parent package for direct access. */ -final class ClientPacketListenerStaffHandler { +public final class ClientPacketListenerStaffHandler { private ClientPacketListenerStaffHandler() { } - static void onEntitiesDestroy(ClientboundRemoveEntitiesPacket packet) { + public static void onEntitiesDestroy(ClientboundRemoveEntitiesPacket packet) { Minecraft mc = Minecraft.getInstance(); ClientLevel world = mc.level; if (world == null) return; @@ -38,7 +38,7 @@ final class ClientPacketListenerStaffHandler { } } - static void onEntitySpawn(ClientboundAddEntityPacket packet) { + public static void onEntitySpawn(ClientboundAddEntityPacket packet) { Minecraft mc = Minecraft.getInstance(); ClientLevel world = mc.level; if (world == null) return; @@ -51,7 +51,7 @@ final class ClientPacketListenerStaffHandler { StaffTracker.INSTANCE.onEntityAppear(player.getUUID(), player.getGameProfile().name()); } - static void onPlayerList(ClientboundPlayerInfoUpdatePacket packet) { + public static void onPlayerList(ClientboundPlayerInfoUpdatePacket packet) { var actions = packet.actions(); boolean listedAffects = actions.contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED) || actions.contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER); @@ -77,7 +77,7 @@ final class ClientPacketListenerStaffHandler { } } - static void onPlayerRemove(ClientboundPlayerInfoRemovePacket packet) { + public static void onPlayerRemove(ClientboundPlayerInfoRemovePacket packet) { for (UUID id : packet.profileIds()) { // убрали из TAB diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerInputHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerInputHandler.java similarity index 84% rename from mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerInputHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerInputHandler.java index c5b7a79..e9c99aa 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerInputHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerInputHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.entity.player; +package dev.loki.lovisual.mixinhandlers.player; import dev.loki.lovisual.events.Events; import dev.loki.lovisual.events.impl.input.SprintControlEvent; @@ -10,14 +10,14 @@ import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.entity.player.Input; import net.minecraft.world.phys.Vec2; -final class LocalPlayerInputHandler { +public final class LocalPlayerInputHandler { - boolean shouldBlockWASD() { + public boolean shouldBlockWASD() { Freecam fc = Modules.get(Freecam.class); return fc != null && fc.isEnabled() && fc.isCameraInput(); } - void handleFreecamAfterInputTick(LocalPlayer self) { + public void handleFreecamAfterInputTick(LocalPlayer self) { Freecam fc = Modules.get(Freecam.class); if (fc == null || !fc.isEnabled() || !fc.isCameraInput()) return; if (self.input == null) return; @@ -26,7 +26,7 @@ final class LocalPlayerInputHandler { self.input.keyPresses = new Input(false, false, false, false, false, false, sprint); } - void handleAfterInputTickSprint(LocalPlayer self) { + public void handleAfterInputTickSprint(LocalPlayer self) { if (self.input == null) return; Input original = self.input.keyPresses; if (original == null) return; @@ -51,7 +51,7 @@ final class LocalPlayerInputHandler { if (!sameInput(original, input)) applyPlayerInput(self, input); } - void applyPlayerInput(LocalPlayer self, Input input) { + public void applyPlayerInput(LocalPlayer self, Input input) { if (self.input == null || input == null) return; InputAccessor accessor = (InputAccessor) self.input; accessor.setPlayerInput(input); @@ -60,9 +60,9 @@ final class LocalPlayerInputHandler { accessor.setMovementVector(new Vec2(sideways, forward).normalized()); } - Input transformDirection(Input input) { return input; } + public Input transformDirection(Input input) { return input; } - boolean postSprintControl(LocalPlayer self, boolean original, SprintControlEvent.Source source) { + public boolean postSprintControl(LocalPlayer self, boolean original, SprintControlEvent.Source source) { if (self.input == null) return original; if (!Events.BUS.hasListeners(SprintControlEvent.class)) return original; Vec2 movement = self.input.getMoveVector(); @@ -71,19 +71,19 @@ final class LocalPlayerInputHandler { return event.shouldSprint(); } - boolean redirectShift(Input in, LocalPlayer self) { + public boolean redirectShift(Input in, LocalPlayer self) { Freecam fc = Modules.get(Freecam.class); if (fc != null && fc.isEnabled() && fc.isCameraInput() && self.getAbilities().flying) return false; return in.shift(); } - boolean sameInput(Input a, Input b) { + public boolean sameInput(Input a, Input b) { if (a == b) return true; if (a == null || b == null) return false; return a.forward() == b.forward() && a.backward() == b.backward() && a.left() == b.left() && a.right() == b.right() && a.jump() == b.jump() && a.shift() == b.shift() && a.sprint() == b.sprint(); } - float impulse(boolean positive, boolean negative) { + public float impulse(boolean positive, boolean negative) { if (positive == negative) return 0.0f; return positive ? 1.0f : -1.0f; } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerMovementHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerMovementHandler.java similarity index 87% rename from mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerMovementHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerMovementHandler.java index 57fa590..73beb3c 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerMovementHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerMovementHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.entity.player; +package dev.loki.lovisual.mixinhandlers.player; import dev.loki.lovisual.events.Events; import dev.loki.lovisual.events.impl.world.EventPushOutOfBlocks; @@ -11,7 +11,7 @@ import net.minecraft.world.entity.PortalProcessor; import net.minecraft.world.phys.Vec3; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -final class LocalPlayerMovementHandler { +public final class LocalPlayerMovementHandler { private float lastHealth = Float.NaN; private boolean frozen; @@ -19,7 +19,7 @@ final class LocalPlayerMovementHandler { private double savedX, savedY, savedZ; private float savedBodyYaw, savedHeadYaw; - void onPortalNausea(LocalPlayer self, CallbackInfo ci) { + public void onPortalNausea(LocalPlayer self, CallbackInfo ci) { PortalProcessor portal = null; if (self instanceof EntityAccessor accessor) portal = accessor.lovisual$getPortalManager(); if (portal != null && portal.isInsidePortalThisTick()) { @@ -31,26 +31,26 @@ final class LocalPlayerMovementHandler { } } - void onHurt(float newHealth) { + public void onHurt(float newHealth) { Freecam fc = Modules.get(Freecam.class); if (fc == null || !fc.isEnabled() || !fc.disableOnDamage()) { lastHealth = newHealth; return; } if (!Float.isNaN(lastHealth) && newHealth < lastHealth) fc.toggle(); lastHealth = newHealth; } - void onPushOutOfBlocks(double x, double z, CallbackInfo ci) { + public void onPushOutOfBlocks(double x, double z, CallbackInfo ci) { if (!Events.BUS.hasListeners(EventPushOutOfBlocks.class)) return; EventPushOutOfBlocks event = new EventPushOutOfBlocks(x, z); Events.BUS.post(event); if (event.isCancelled()) ci.cancel(); } - boolean shouldFreezeNow() { + public boolean shouldFreezeNow() { Freecam freecam = Modules.get(Freecam.class); return freecam != null && freecam.freezePlayer() && freecam.isCameraInput(); } - void onFreezeTick(LocalPlayer self, CallbackInfo ci) { + public void onFreezeTick(LocalPlayer self, CallbackInfo ci) { if (!shouldFreezeNow()) { if (frozen) { self.setDeltaMovement(savedVelocity); frozen = false; } return; diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerNetworkHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerNetworkHandler.java similarity index 81% rename from mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerNetworkHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerNetworkHandler.java index 9a7bd0a..01fd56f 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerNetworkHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerNetworkHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.entity.player; +package dev.loki.lovisual.mixinhandlers.player; import dev.loki.lovisual.events.Events; import dev.loki.lovisual.events.impl.network.EventPostSync; @@ -8,13 +8,13 @@ import dev.loki.lovisual.util.combat.rotation.SprintController; import net.minecraft.client.player.LocalPlayer; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -final class LocalPlayerNetworkHandler { +public final class LocalPlayerNetworkHandler { private Runnable eventPostAction; private EventSync activeSyncEvent; private boolean postUpdateLock; - boolean onSendPositionHead(LocalPlayer self, CallbackInfo ci) { + public boolean onSendPositionHead(LocalPlayer self, CallbackInfo ci) { if (!Events.BUS.hasListeners(EventSync.class)) return false; eventPostAction = null; EventSync event = new EventSync(self.getYRot(), self.getXRot()); @@ -30,15 +30,15 @@ final class LocalPlayerNetworkHandler { return false; } - void onSendPositionReturn() { + public void onSendPositionReturn() { if (Events.BUS.hasListeners(EventPostSync.class)) Events.BUS.post(new EventPostSync()); if (eventPostAction != null) { eventPostAction.run(); eventPostAction = null; } activeSyncEvent = null; } - EventSync getActiveSyncEvent() { return activeSyncEvent; } + public EventSync getActiveSyncEvent() { return activeSyncEvent; } - boolean onPostPlayerUpdate(LocalPlayer self, CallbackInfo ci, Runnable sendPosition) { + public boolean onPostPlayerUpdate(LocalPlayer self, CallbackInfo ci, Runnable sendPosition) { if (!Events.BUS.hasListeners(PostPlayerUpdateEvent.class)) return false; if (postUpdateLock) return false; PostPlayerUpdateEvent event = new PostPlayerUpdateEvent(); @@ -56,7 +56,7 @@ final class LocalPlayerNetworkHandler { return true; } - boolean shouldBlockSprintNetwork(boolean original) { + public boolean shouldBlockSprintNetwork(boolean original) { if (SprintController.INSTANCE.isSprintBlocked()) return false; return original; } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerRotationHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerRotationHandler.java similarity index 84% rename from mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerRotationHandler.java rename to mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerRotationHandler.java index 48590c8..dd455ae 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/handlers/LocalPlayerRotationHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/player/LocalPlayerRotationHandler.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.entity.player; +package dev.loki.lovisual.mixinhandlers.player; import dev.loki.lovisual.events.impl.network.EventSync; import dev.loki.lovisual.mixininterface.entity.ILocalPlayer; @@ -7,14 +7,14 @@ import dev.loki.lovisual.util.aiming.rotation.RotationManager; import net.minecraft.client.player.LocalPlayer; import net.minecraft.util.Mth; -final class LocalPlayerRotationHandler { +public final class LocalPlayerRotationHandler { private double rotationPrevX; private double rotationPrevZ; private float rotationPrevBodyYaw; private boolean rotationBodyYawInitialized; - void onTickCache(LocalPlayer self) { + public void onTickCache(LocalPlayer self) { if (self instanceof ILocalPlayer access) { access.lovisual$setLastYaw(self.getYRot()); access.lovisual$setLastPitch(self.getXRot()); @@ -28,31 +28,31 @@ final class LocalPlayerRotationHandler { } } - float resolveSendYaw(LocalPlayer self, float original, EventSync active) { + public float resolveSendYaw(LocalPlayer self, float original, EventSync active) { updateVisualBodyYaw(self); if (active != null && active.hasRotationOverride()) return active.getYaw(); Rotation rotation = RotationManager.INSTANCE.getCurrentRotation(); return rotation != null ? rotation.yaw() : original; } - float resolveSendPitch(float original, EventSync active) { + public float resolveSendPitch(float original, EventSync active) { if (active != null && active.hasRotationOverride()) return Mth.clamp(active.getPitch(), -90.0f, 90.0f); Rotation rotation = RotationManager.INSTANCE.getCurrentRotation(); return rotation != null ? Mth.clamp(rotation.pitch(), -90.0f, 90.0f) : original; } - float resolveTickYaw(LocalPlayer self, float original) { + public float resolveTickYaw(LocalPlayer self, float original) { updateVisualBodyYaw(self); Rotation rotation = RotationManager.INSTANCE.getCurrentRotation(); return rotation != null ? rotation.yaw() : original; } - float resolveTickPitch(float original) { + public float resolveTickPitch(float original) { Rotation rotation = RotationManager.INSTANCE.getCurrentRotation(); return rotation != null ? Mth.clamp(rotation.pitch(), -90.0f, 90.0f) : original; } - void updateVisualBodyYaw(LocalPlayer self) { + public void updateVisualBodyYaw(LocalPlayer self) { Rotation rotation = RotationManager.INSTANCE.getCurrentRotation(); if (rotation == null) { rotationPrevX = self.getX(); @@ -67,7 +67,7 @@ final class LocalPlayerRotationHandler { rotationPrevZ = self.getZ(); } - float simulateBodyYaw(float yaw, float prevBodyYaw, double prevX, double prevZ, double currentX, double currentZ, float handSwing) { + public float simulateBodyYaw(float yaw, float prevBodyYaw, double prevX, double prevZ, double currentX, double currentZ, float handSwing) { double motionX = currentX - prevX; double motionZ = currentZ - prevZ; float motionSquared = (float) (motionX * motionX + motionZ * motionZ); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/LocalPlayerMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/LocalPlayerMixin.java index 65bd673..f0370e5 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/LocalPlayerMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/entity/player/LocalPlayerMixin.java @@ -1,5 +1,9 @@ package dev.loki.lovisual.mixins.entity.player; +import dev.loki.lovisual.mixinhandlers.player.LocalPlayerInputHandler; +import dev.loki.lovisual.mixinhandlers.player.LocalPlayerRotationHandler; +import dev.loki.lovisual.mixinhandlers.player.LocalPlayerNetworkHandler; +import dev.loki.lovisual.mixinhandlers.player.LocalPlayerMovementHandler; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import dev.loki.lovisual.events.impl.input.SprintControlEvent; import dev.loki.lovisual.mixininterface.entity.ILocalPlayer; diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/gui/chat/CommandSuggestionsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/gui/chat/CommandSuggestionsMixin.java new file mode 100644 index 0000000..30ba6df --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/gui/chat/CommandSuggestionsMixin.java @@ -0,0 +1,58 @@ +package dev.loki.lovisual.mixins.gui.chat; + +import com.mojang.brigadier.suggestion.Suggestions; +import dev.loki.lovisual.Lifecycle; +import dev.loki.lovisual.features.command.core.CommandManager; +import dev.loki.lovisual.features.command.util.CommandPrefix; +import dev.loki.lovisual.features.command.suggest.SuggestionPlan; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; +import net.minecraft.client.gui.components.CommandSuggestions; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.util.FormattedCharSequence; +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.concurrent.CompletableFuture; + +/** + * Baritone-style autocomplete for client commands in the vanilla chat: while the input starts + * with the command prefix, the popup lists our commands and arguments as you type. + * BetterChat draws its own popup, so it keeps its existing path. + */ +@Mixin(CommandSuggestions.class) +public abstract class CommandSuggestionsMixin { + @Shadow @Final private EditBox input; + @Shadow @Final private List commandUsage; + @Shadow private CompletableFuture pendingSuggestions; + @Shadow private CommandSuggestions.SuggestionsList suggestions; + @Shadow private boolean keepSuggestions; + + @Shadow + public abstract void showSuggestions(boolean narrateFirstSuggestion); + + @Inject(method = "updateCommandInfo", at = @At("HEAD"), cancellable = true) + private void lovisual$clientCommandSuggestions(CallbackInfo ci) { + if (!Lifecycle.isActive() || BetterChat.isActive()) return; + String text = input.getValue(); + if (!CommandPrefix.isCommandLike(text)) return; + + ci.cancel(); + commandUsage.clear(); + if (!keepSuggestions) { + input.setSuggestion(null); + suggestions = null; + } + SuggestionPlan plan = SuggestionPlan.of(CommandManager.suggest(text, input.getCursorPosition())); + if (plan.suggestions() != null) { + pendingSuggestions = CompletableFuture.completedFuture(plan.suggestions()); + showSuggestions(false); + } else if (plan.ghostHint() != null && !keepSuggestions) { + input.setSuggestion(" " + plan.ghostHint()); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/gui/screens/TitleScreenMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/gui/screens/TitleScreenMixin.java index 8418196..1536126 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/gui/screens/TitleScreenMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/gui/screens/TitleScreenMixin.java @@ -7,7 +7,7 @@ import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import dev.loki.lovisual.features.gui.mainmenu.LoVisualMainMenuScreen; +import dev.loki.lovisual.features.gui.mainmenu.MainMenus; @Mixin(TitleScreen.class) public abstract class TitleScreenMixin { @@ -15,11 +15,11 @@ public abstract class TitleScreenMixin { @Inject(method = "init", at = @At("HEAD"), cancellable = true) private void lovisual$replaceTitleScreen(CallbackInfo ci) { - if (LoVisualMainMenuScreen.shouldUseVanillaTitleScreen()) return; + if (MainMenus.shouldUseVanillaTitleScreen()) return; Minecraft mc = Minecraft.getInstance(); if (mc == null) return; - if (ClientScreen.current() instanceof LoVisualMainMenuScreen) return; - ClientScreen.show(mc, new LoVisualMainMenuScreen()); + if (MainMenus.isMainMenu(ClientScreen.current())) return; + ClientScreen.show(mc, MainMenus.create()); ci.cancel(); } } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/hud/GuiMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/hud/GuiMixin.java index a45daae..8d91ecc 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/hud/GuiMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/hud/GuiMixin.java @@ -1,5 +1,9 @@ package dev.loki.lovisual.mixins.hud; +import dev.loki.lovisual.mixinhandlers.hud.GuiOverlayHandler; +import dev.loki.lovisual.mixinhandlers.hud.GuiHudBarHandler; +import dev.loki.lovisual.mixinhandlers.hud.GuiHealthLayoutHandler; +import dev.loki.lovisual.mixinhandlers.hud.GuiTooltipHandler; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java index 35c81ff..4ac7e2e 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java @@ -1,5 +1,8 @@ package dev.loki.lovisual.mixins.network.game; +import dev.loki.lovisual.mixinhandlers.network.ClientPacketListenerSoundHandler; +import dev.loki.lovisual.mixinhandlers.network.ClientPacketListenerStaffHandler; +import dev.loki.lovisual.mixinhandlers.network.ClientPacketListenerPvpHandler; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.network.protocol.game.*; import org.slf4j.Logger; diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java index 9d1299c..1a202fb 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java @@ -20,6 +20,7 @@ import org.spongepowered.asm.mixin.injection.ModifyVariable; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.HandScripts; import dev.loki.lovisual.util.animation.hand.HandDynamics; import dev.loki.lovisual.features.module.modules.visuals.camera.ViewModel; import dev.loki.lovisual.util.animation.EquipProgressComposer; @@ -31,6 +32,8 @@ import dev.loki.lovisual.features.module.modules.visuals.scene.visual.Chams; public abstract class ItemInHandRendererMixin { @Unique private boolean lovisual$handPosePushed; + @Unique private boolean lovisual$scriptPosePushed; + @Unique private boolean lovisual$itemPosePushed; @Inject(method = "tick", at = @At("HEAD"), cancellable = true) private void lovisual$skipTickWithoutPlayer(CallbackInfo ci) { @@ -56,7 +59,11 @@ public abstract class ItemInHandRendererMixin { HandAnimState handAnim = holdMyItems.handAnim(); if (handAnim == null) return; - lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, item, matrices); + if (holdMyItems.dynamicsActive()) { + lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, item, matrices); + } + lovisual$scriptPosePushed = HandScripts.applyHandPose( + holdMyItems, player, hand, swingProgress, equipProgress, tickDelta, item, matrices); } @Inject(method = "submitArmWithItem", at = @At("RETURN")) @@ -65,27 +72,20 @@ public abstract class ItemInHandRendererMixin { float swingProgress, ItemStack item, float equipProgress, PoseStack matrices, SubmitNodeCollector queue, int light, CallbackInfo ci ) { + if (lovisual$scriptPosePushed) { + lovisual$scriptPosePushed = false; + matrices.popPose(); + } if (!lovisual$handPosePushed) return; lovisual$handPosePushed = false; matrices.popPose(); } - @Inject( - method = "submitArmWithItem", - at = @At("HEAD") - ) + @Inject(method = "submitArmWithItem", at = @At("HEAD")) private void lovisual$applyOffsets( - AbstractClientPlayer player, - float tickDelta, - float pitch, - InteractionHand hand, - float swingProgress, - ItemStack item, - float equipProgress, - PoseStack matrices, - SubmitNodeCollector queue, - int light, - CallbackInfo ci + AbstractClientPlayer player, float tickDelta, float pitch, InteractionHand hand, + float swingProgress, ItemStack item, float equipProgress, PoseStack matrices, + SubmitNodeCollector queue, int light, CallbackInfo ci ) { ViewModel viewModel = Modules.get(ViewModel.class); if (viewModel == null || !viewModel.isEnabled()) return; @@ -112,7 +112,7 @@ public abstract class ItemInHandRendererMixin { ViewModel viewModel = Modules.get(ViewModel.class); HoldMyItems holdMyItems = Modules.get(HoldMyItems.class); boolean lowerEnabled = viewModel != null && viewModel.isEnabled(); - boolean curveEnabled = holdMyItems != null && holdMyItems.isEnabled() + boolean curveEnabled = holdMyItems != null && holdMyItems.isEnabled() && holdMyItems.dynamicsActive() && !(hand == InteractionHand.OFF_HAND && !holdMyItems.offhandToo.get()); if (!lowerEnabled && !curveEnabled) return equipProgress; return EquipProgressComposer.compose( @@ -146,6 +146,8 @@ public abstract class ItemInHandRendererMixin { int light, CallbackInfo ci ) { + HoldMyItems held = Modules.get(HoldMyItems.class); + if (held != null) lovisual$itemPosePushed = HandScripts.applyItemPose(held, entity, stack, ctx, ms); ViewModel viewModel = Modules.get(ViewModel.class); if (viewModel == null || !viewModel.isEnabled()) return; if (!ctx.firstPerson()) return; @@ -155,6 +157,16 @@ public abstract class ItemInHandRendererMixin { } } + @Inject(method = "renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/item/ItemDisplayContext;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V", + at = @At("RETURN")) + private void lovisual$restoreItemPose( + LivingEntity entity, ItemStack stack, ItemDisplayContext ctx, PoseStack ms, + SubmitNodeCollector queue, int light, CallbackInfo ci) { + if (!lovisual$itemPosePushed) return; + lovisual$itemPosePushed = false; + ms.popPose(); + } + @Inject( method = "swingArm", at = @At("HEAD"), diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/GameRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/main/GameRendererMixin.java index c537a0e..e1e8306 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/GameRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/main/GameRendererMixin.java @@ -28,6 +28,9 @@ import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import dev.loki.lovisual.config.i18n.MainConfig; +import dev.loki.lovisual.render.gamerenderer.GameRendererProjectionHelper; +import dev.loki.lovisual.render.gamerenderer.GameRendererWorldEngine; +import dev.loki.lovisual.render.gamerenderer.GameRendererWorldPost; import dev.loki.lovisual.features.gui.hud.nondraggable.impl.buttons.BetterButtons; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.visuals.camera.AspectRatio; diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/Draw.java b/mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/Draw.java new file mode 100644 index 0000000..aec2b29 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/Draw.java @@ -0,0 +1,62 @@ +package dev.loki.lovisual.render.engine.renderer.quick; + +import dev.loki.lovisual.render.engine.renderer.Renderer2D; + +/** + * Short static entry point for everyday UI drawing on {@link Renderer2D#COLOR}: no softness or + * mask parameters, color last, one radius. Use the full Renderer2D facade for anything exotic. + * All calls are no-ops until the renderer is initialised. + */ +public final class Draw { + private static final float SHADOW_INNER_ALPHA = 0.03f; + private static final float SHINE_ANGLE = 135f; + + private Draw() { + } + + public static void rect(float x, float y, float w, float h, int argb) { + rect(x, y, w, h, 0f, argb); + } + + public static void rect(float x, float y, float w, float h, float radius, int argb) { + Renderer2D r = Renderer2D.COLOR; + if (r != null) r.roundedRect(x, y, w, h, radius, argb); + } + + public static void rect(float x, float y, float w, float h, + float topLeft, float topRight, float bottomRight, float bottomLeft, int argb) { + Renderer2D r = Renderer2D.COLOR; + if (r != null) r.roundedRectCorners(x, y, w, h, topLeft, topRight, bottomRight, bottomLeft, argb); + } + + public static void gradient(float x, float y, float w, float h, float radius, + int startArgb, int endArgb, float angleDeg) { + Renderer2D r = Renderer2D.COLOR; + if (r != null) r.roundedRectGradient(x, y, w, h, radius, startArgb, endArgb, angleDeg); + } + + public static void outline(float x, float y, float w, float h, float radius, float thickness, int argb) { + Renderer2D r = Renderer2D.COLOR; + if (r != null) r.roundedRectStroke(x, y, w, h, radius, thickness, argb); + } + + /** White outline that shines at the top-left and bottom-right corners, like a glass edge. */ + public static void glassOutline(float x, float y, float w, float h, float radius, float thickness, + float alpha, float shine) { + Renderer2D r = Renderer2D.COLOR; + if (r == null) return; + int[] c = GlassEdge.stops(alpha, shine); + r.roundedRectStrokeGradient(x, y, w, h, radius, thickness, c[0], c[1], SHINE_ANGLE); + r.roundedRectStrokeGradient(x, y, w, h, radius, thickness, c[2], c[3], SHINE_ANGLE); + } + + public static void shadow(float x, float y, float w, float h, float radius, float blur, int argb) { + Renderer2D r = Renderer2D.COLOR; + if (r != null) r.roundedRectSoftShadow(x, y, w, h, radius, blur, SHADOW_INNER_ALPHA, argb); + } + + public static void circle(float cx, float cy, float radius, int argb) { + Renderer2D r = Renderer2D.COLOR; + if (r != null) r.circle(cx, cy, radius, argb); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdge.java b/mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdge.java new file mode 100644 index 0000000..efd2275 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdge.java @@ -0,0 +1,33 @@ +package dev.loki.lovisual.render.engine.renderer.quick; + +/** Alpha stops of the two-layer "glass shine" outline: bright top-left and bottom-right, dim between. */ +public final class GlassEdge { + private static final int WHITE = 0x00FFFFFF; + + private GlassEdge() { + } + + /** + * @param alpha overall opacity in [0, 1] + * @param shine highlight strength in [0, 1] + * @return {startA, endA, startB, endB} ARGB colors: layer A fades TL to BR, layer B fades BR to TL + */ + public static int[] stops(float alpha, float shine) { + float a = clamp01(alpha); + float s = clamp01(shine); + return new int[]{ + white(a * (0.35f + 0.30f * s)), + white(a * 0.08f), + white(a * 0.06f), + white(a * (0.22f + 0.28f * s)) + }; + } + + private static int white(float alpha) { + return (Math.round(clamp01(alpha) * 255f) << 24) | WHITE; + } + + private static float clamp01(float v) { + return Math.max(0f, Math.min(1f, v)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/texture/TextureStorage.java b/mod/src/main/java/dev/loki/lovisual/render/engine/texture/TextureStorage.java index a9f0a97..40ff4d1 100644 --- a/mod/src/main/java/dev/loki/lovisual/render/engine/texture/TextureStorage.java +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/texture/TextureStorage.java @@ -52,6 +52,9 @@ public enum TextureStorage { public static final Identifier CAPTURE = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/capture.png"); + public static final Identifier SWORD = + Identifier.fromNamespaceAndPath("lovisual", "textures/particles/sword.png"); + public static void preload() { Minecraft mc = Minecraft.getInstance(); if (mc == null) return; @@ -65,6 +68,7 @@ public enum TextureStorage { tm.getTexture(FUNNEL_DISTORTION); tm.getTexture(PARTICLE_STARS); tm.getTexture(CAPTURE); + tm.getTexture(SWORD); tm.getTexture(TROLLFACE_1); tm.getTexture(TROLLFACE_2); tm.getTexture(TROLLFACE_3); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererProjectionHelper.java b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererProjectionHelper.java similarity index 90% rename from mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererProjectionHelper.java rename to mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererProjectionHelper.java index e3793bf..dcf0e18 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererProjectionHelper.java +++ b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererProjectionHelper.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.render.main; +package dev.loki.lovisual.render.gamerenderer; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Camera; @@ -22,18 +22,18 @@ import org.joml.Vector3f; * Projection math extracted from GameRendererMixin: aspect ratio, view bobbing, * nausea and MSAA depth queries. Holds per-frame scratch matrices only. */ -final class GameRendererProjectionHelper { +public final class GameRendererProjectionHelper { private final Matrix4f inverseBobScratch = new Matrix4f(); private final org.joml.Quaternionf conjugateScratch = new org.joml.Quaternionf(); - interface BobInvoker { + public interface BobInvoker { void lovisual$bobHurt(CameraRenderState cameraRenderState, PoseStack matrices); void lovisual$bobView(CameraRenderState cameraRenderState, PoseStack matrices); } - void applyAspect(CameraRenderState state) { + public void applyAspect(CameraRenderState state) { AspectRatio ar = Modules.get(AspectRatio.class); if (ar == null || !ar.isEnabled()) return; float mul = ar.getMultiplier(); @@ -42,7 +42,7 @@ final class GameRendererProjectionHelper { state.projectionMatrix.m00(state.projectionMatrix.m00() * mul); } - Matrix4f buildWorldProjection(Camera camera, float fov, PoseStack viewEffects, float tickDelta, + public Matrix4f buildWorldProjection(Camera camera, float fov, PoseStack viewEffects, float tickDelta, float spinningTime, float spinningSpeed, Minecraft minecraft, BobInvoker bobInvoker) { CameraRenderState tmp = new CameraRenderState(); @@ -91,22 +91,22 @@ final class GameRendererProjectionHelper { projection.rotate(-angle, axis); } - boolean isWorldTranslucent(FogType type) { + public boolean isWorldTranslucent(FogType type) { return type == FogType.WATER || type == FogType.LAVA || type == FogType.POWDER_SNOW; } - boolean needsResolvedMainDepth() { + public boolean needsResolvedMainDepth() { ReimaginedVisual module = Modules.get(ReimaginedVisual.class); if (module == null) return false; if (module.needsResolvedMainDepthCapture()) return true; return MainConfig.get().getMsaa3dSamples() > 1 && module.isActive(); } - Matrix4f inverseBobScratch() { + public Matrix4f inverseBobScratch() { return inverseBobScratch; } - org.joml.Quaternionf conjugateScratch() { + public org.joml.Quaternionf conjugateScratch() { return conjugateScratch; } } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldDebug.java b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldDebug.java similarity index 94% rename from mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldDebug.java rename to mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldDebug.java index 8cf0816..a3a8ef1 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldDebug.java +++ b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldDebug.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.render.main; +package dev.loki.lovisual.render.gamerenderer; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vertex.PoseStack; @@ -16,7 +16,7 @@ import org.joml.Matrix4f; * Manual-only render debug probes (compile-time disabled). Extracted from * GameRendererMixin to keep the mixin hook-only. */ -final class GameRendererWorldDebug { +public final class GameRendererWorldDebug { private static final boolean CLIP_SPACE = false; private static final boolean PRIMITIVE = false; @@ -26,7 +26,7 @@ final class GameRendererWorldDebug { private long lastPrimitiveLogMs; /** Renders the NDC clip-space probe; when it ran the caller must return early. */ - boolean clipSpaceIfNeeded() { + public boolean clipSpaceIfNeeded() { if (!CLIP_SPACE) return false; var mv = RenderSystem.getModelViewStack(); mv.pushMatrix(); @@ -60,7 +60,7 @@ final class GameRendererWorldDebug { } /** Draws wire cubes 3 blocks ahead of the camera plus a throttled matrix diag log. */ - void primitiveIfNeeded(Renderer3D renderer, Renderer3D depthRenderer, Camera camera, Matrix4f projection) { + public void primitiveIfNeeded(Renderer3D renderer, Renderer3D depthRenderer, Camera camera, Matrix4f projection) { if (!PRIMITIVE) return; Vec3 camPos = camera.position(); float yaw = camera.yRot() * ((float) Math.PI / 180.0f); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldEngine.java b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldEngine.java similarity index 97% rename from mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldEngine.java rename to mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldEngine.java index 5bb2c25..21d0d43 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldEngine.java +++ b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldEngine.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.render.main; +package dev.loki.lovisual.render.gamerenderer; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vertex.PoseStack; @@ -31,7 +31,7 @@ import org.joml.Matrix4fStack; * World-render engine bodies extracted from GameRendererMixin: the * BEFORE_TRANSLUCENT/END_MAIN world pass and the post-process world pass. */ -final class GameRendererWorldEngine { +public final class GameRendererWorldEngine { private final GameRendererWorldDebug debug = new GameRendererWorldDebug(); private final PoseStack matrices = new PoseStack(); @@ -43,7 +43,7 @@ final class GameRendererWorldEngine { private Renderer3D postRenderer; private Renderer3D postDepthRenderer; - interface Host extends GameRendererProjectionHelper.BobInvoker { + public interface Host extends GameRendererProjectionHelper.BobInvoker { Minecraft lovisual$minecraft(); Camera lovisual$mainCamera(); @@ -74,7 +74,7 @@ final class GameRendererWorldEngine { LoVisualRenderStage.WORLD_POST_PROCESS, "3d:post_modules", "modules", "3d:post_flush", "flush", true); - void renderWorld(Host host, DeltaTracker tickCounter) { + public void renderWorld(Host host, DeltaTracker tickCounter) { try (ProfilerPhase.Scope phaseScope = ProfilerPhase.scope("3d:world"); TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("3d:world")) { Frame frame = prepareFrame(host, tickCounter); @@ -111,7 +111,7 @@ final class GameRendererWorldEngine { } } - void renderPostWorld(Host host, DeltaTracker tickCounter) { + public void renderPostWorld(Host host, DeltaTracker tickCounter) { try (ProfilerPhase.Scope phaseScope = ProfilerPhase.scope("3d:world_post"); TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("3d:world_post")) { Frame frame = prepareFrame(host, tickCounter); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldPost.java b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldPost.java similarity index 93% rename from mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldPost.java rename to mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldPost.java index d3f5c7f..11df9b4 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/handlers/GameRendererWorldPost.java +++ b/mod/src/main/java/dev/loki/lovisual/render/gamerenderer/GameRendererWorldPost.java @@ -1,4 +1,4 @@ -package dev.loki.lovisual.mixins.render.main; +package dev.loki.lovisual.render.gamerenderer; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; @@ -26,15 +26,15 @@ import dev.loki.lovisual.util.screen.ClientScreen; /** * Pre/post-hand post-processing and HUD drain bodies extracted from GameRendererMixin. */ -final class GameRendererWorldPost { +public final class GameRendererWorldPost { private final GameRendererWorldEngine engine; - GameRendererWorldPost(GameRendererWorldEngine engine) { + public GameRendererWorldPost(GameRendererWorldEngine engine) { this.engine = engine; } - void preHand(GameRendererWorldEngine.Host host, DeltaTracker tickCounter) { + public void preHand(GameRendererWorldEngine.Host host, DeltaTracker tickCounter) { if (IrisRuntime.isShaderpackRendererActive() && IrisCompatibilityGuards.deferIrisFinalizationForSecondHandScene()) { return; @@ -80,7 +80,7 @@ final class GameRendererWorldPost { } } - void afterHand(GameRendererWorldEngine.Host host, DeltaTracker tickCounter) { + public void afterHand(GameRendererWorldEngine.Host host, DeltaTracker tickCounter) { ProfilerPhase.begin("3d:post_after_hand"); try (TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("3d:post_after_hand")) { Minecraft minecraft = host.lovisual$minecraft(); @@ -95,7 +95,7 @@ final class GameRendererWorldPost { ProfilerPhase.end("3d:post_after_hand"); } - void beforeVanillaGui(Minecraft minecraft, DeltaTracker tickCounter) { + public void beforeVanillaGui(Minecraft minecraft, DeltaTracker tickCounter) { MenuBackgroundRenderer.drainDeferred(minecraft); Renderer2D.captureWorldGlassSource(); if (AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.SCREEN_BEFORE_VANILLA_GUI)) { @@ -109,7 +109,7 @@ final class GameRendererWorldPost { drainHudLayersBelowScreenGui(); } - void afterVanillaGui(DeltaTracker tickCounter) { + public void afterVanillaGui(DeltaTracker tickCounter) { Renderer2D.drainDeferred2D(Renderer2D.Deferred2DLayer.AFTER_VANILLA_GUI); if (AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.SCREEN_AFTER_VANILLA_GUI)) { float tickDelta = tickCounter.getGameTimeDeltaPartialTick(true); diff --git a/mod/src/main/resources/assets/lovisual/holdmyitems/hand_pose.lua b/mod/src/main/resources/assets/lovisual/holdmyitems/hand_pose.lua new file mode 100644 index 0000000..353ae41 --- /dev/null +++ b/mod/src/main/resources/assets/lovisual/holdmyitems/hand_pose.lua @@ -0,0 +1,28 @@ +-- LoVisual HoldMyItems: поза руки. Выполняется каждый кадр для каждой руки. +-- Положите свою версию в config/lovisual/holdmyitems/hand_pose.lua (перезагрузка на лету). +-- Глобалы: M (матрица), P (игрок), I (предмет), Easings, registry, mainHand, rightArm, +-- swingProgress, equipProgress, tickDelta, usingItem, useDuration, time. + +global.breath = 0.0 + +local side = rightArm and 1 or -1 +local t = time + +-- лёгкое дыхание руки +M:moveY(M:sin(t * 1.6) * 0.004) +M:rotateZ(M:sin(t * 1.2) * 0.6 * side) + +-- при спринте рука чуть опускается и вперёд +if P:isSprinting() then + breath = M:lerp(breath, 1.0, 0.08) +else + breath = M:lerp(breath, 0.0, 0.08) +end +M:moveY(-0.05 * breath) +M:rotateX(8 * breath, 0, 0, -0.5) + +-- мечи: замах чуть шире, c упругим возвратом +if I:isSword() and swingProgress > 0 then + local k = Easings:easeOutBack(swingProgress) + M:rotateZ(-14 * k * side * (1 - swingProgress)) +end diff --git a/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua b/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua new file mode 100644 index 0000000..0034b69 --- /dev/null +++ b/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua @@ -0,0 +1,18 @@ +-- LoVisual HoldMyItems: поза самого предмета (после позы руки). +-- Глобалы те же, что в hand_pose.lua. + +-- блоки в руке слегка поворачиваются при ходьбе +if I:isBlock() then + M:rotateY(M:sin(time * 1.4) * 1.5) +end + +-- зачарованные предметы мягко "дышат" +if I:isEnchanted() then + local s = 1 + M:sin(time * 2.0) * 0.015 + M:scale(s, s, s) +end + +-- еда и питьё: покачивание при использовании +if usingItem and (I:getUseAction() == 'eat' or I:getUseAction() == 'drink') then + M:rotateZ(M:sin(useDuration * 1.1) * 3) +end 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 3088883..750af33 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3,6 +3,11 @@ "clickgui.settings.tab.ui": "UI", "clickgui.settings.tab.themes": "Themes", "clickgui.settings.tab.main_settings": "Main Settings", + "clickgui.settings.category.image": "Image", + "clickgui.settings.category.main_menu": "Main menu", + "clickgui.settings.category.security": "Security", + "clickgui.settings.category.utility": "Utility", + "clickgui.settings.category.miscellanious": "Miscellaneous", "clickgui.settings.tab.configs": "Configs", "clickgui.settings.config.type.modules": "Modules", "clickgui.settings.config.type.hud": "Hud", @@ -642,6 +647,20 @@ "setting.targetesp.mode.option.crystals": "Crystals", "setting.targetesp.mode.option.blackhole": "Blackhole", "setting.targetesp.mode.option.vortex": "Vortex", + "setting.targetesp.mode.option.constellation": "Constellation", + "setting.targetesp.constellation_stars": "Stars", + "setting.targetesp.constellation_speed": "Constellation speed", + "setting.targetesp.constellation_orbit": "Orbit", + "setting.targetesp.constellation_lines": "Lines", + "setting.targetesp.constellation_point_size": "Star size", + "setting.targetesp.constellation_glow": "Glow", + "setting.targetesp.mode.option.swords": "Swords", + "setting.targetesp.swords_speed": "Orbit time (ms)", + "setting.targetesp.swords_radius": "Orbit radius", + "setting.targetesp.swords_size": "Sword size", + "setting.targetesp.swords_bob": "Bobbing", + "setting.targetesp.swords_glow": "Glow", + "setting.targetesp.swords_physics": "Spring physics", "setting.targetesp.vortex_speed": "Vortex speed", "setting.targetesp.color": "Color", "setting.targetesp.color_mode": "Color mode", @@ -667,14 +686,23 @@ "setting.targetesp.esp_amplitude": "ESP amplitude", "setting.targetesp.capture_spin_speed": "Capture spin speed", "setting.jumpcircles.mode": "Mode", - "setting.jumpcircles.mode.option.Default": "Default", + "setting.jumpcircles.mode.option.Circle": "Circle", + "setting.jumpcircles.mode.option.Ring": "Ring", "setting.jumpcircles.mode.option.Bubble": "Bubble", - "setting.jumpcircles.mode.option.Pulse": "Pulse", - "setting.jumpcircles.ease_out": "Ease out", + "setting.jumpcircles.mode.option.Star": "Star", + "setting.jumpcircles.mode.option.Diamond": "Diamond", + "setting.jumpcircles.mode.option.Spiral": "Spiral", + "setting.jumpcircles.mode.option.Hexagon": "Hexagon", + "setting.jumpcircles.mode.option.Wave": "Wave", + "setting.jumpcircles.mode.option.Cross": "Cross", + "setting.jumpcircles.mode.option.Petals": "Petals", "setting.jumpcircles.rotate_speed": "Rotate speed", - "setting.jumpcircles.circle_scale": "Circle scale", - "setting.jumpcircles.pulse_max_size": "Pulse max size", - "setting.jumpcircles.pulse_lifetime": "Pulse lifetime", + "setting.jumpcircles.speed": "Speed", + "setting.jumpcircles.fade_speed": "Fade speed", + "setting.jumpcircles.thickness": "Thickness", + "setting.jumpcircles.segments": "Segments", + "setting.jumpcircles.max_circles": "Max circles", + "setting.jumpcircles.circle_scale": "Radius", "setting.jumpcircles.only_self": "Only self", "setting.jumpcircles.depth_test": "Depth test", "setting.jumpcircles.color_mode": "Color mode", @@ -715,6 +743,17 @@ "setting.killeffect.mode.option.FallingLava": "Falling lava", "setting.killeffect.mode.option.EmbersArc": "Embers arc", "setting.killeffect.mode.option.Lightning": "Lightning", + "setting.killeffect.mode.option.Blood": "Blood", + "setting.killeffect.blood_color": "Blood color", + "setting.killeffect.blood_style": "Splatter style", + "setting.killeffect.blood_style.option.Burst": "Burst", + "setting.killeffect.blood_style.option.Fountain": "Fountain", + "setting.killeffect.blood_style.option.Ring": "Ring", + "setting.killeffect.blood_style.option.Spray": "Spray", + "setting.killeffect.blood_amount": "Amount", + "setting.killeffect.blood_power": "Burst power", + "setting.killeffect.blood_size": "Drop size", + "setting.killeffect.blood_time": "Stain time (s)", "setting.killeffect.y_speed": "Y speed", "setting.killeffect.play_sound": "Play sound", "setting.killeffect.sound_volume": "Sound volume", @@ -3302,6 +3341,10 @@ "setting.autorespawn.delay": "Delay", "module.deathcoords.description": "Sends the coordinates of your latest death to the chat.", "setting.deathcoords.show_dimension": "Show dimension", + "setting.holdmyitems.mode": "Mode", + "setting.holdmyitems.mode.option.Dynamics": "Dynamics (Java)", + "setting.holdmyitems.mode.option.Scripts": "Scripts (Lua)", + "setting.holdmyitems.mode.option.Both": "Both", "setting.holdmyitems.sway_intensity": "Sway intensity", "setting.holdmyitems.ease_strength": "Equip ease strength", "setting.holdmyitems.offhand": "Apply to offhand", @@ -3423,5 +3466,167 @@ "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.", - "command.link.description": "Link this client to your LoVisual account, or check/unlink an existing link." + "command.link.description": "Link this client to your LoVisual account, or check/unlink an existing link.", + "setting.main_config.menuLayout": "Main menu style", + "setting.main_config.menuLayout.option.orbs": "Orbs", + "setting.main_config.menuLayout.option.hero": "Hero", + "setting.main_config.menuLayout.desc": "Look of the main menu: Orbs is a clock with round buttons, Hero is a greeting with a button list and an account card.", + "setting.main_config.menuBg.desc": "Animated shader behind the main menu. Off switches back to the vanilla title screen.", + "setting.main_config.menuBgUseTheme.desc": "Tint the menu background with the current ClickGUI theme colors.", + "setting.main_config.menuClockShowSeconds.desc": "Show seconds in the Orbs menu clock.", + "screen.lovisual.hero_menu.greeting": "WELCOME BACK, %s", + "screen.lovisual.hero_menu.title1": "Everything is ready,", + "screen.lovisual.hero_menu.title2": "this is LoVisual.", + "screen.lovisual.hero_menu.subtitle": "Where to today: a server or your own world?", + "screen.lovisual.hero_menu.multiplayer": "Multiplayer", + "screen.lovisual.hero_menu.singleplayer": "Singleplayer", + "screen.lovisual.hero_menu.accounts": "Accounts", + "screen.lovisual.hero_menu.addons": "Addons", + "screen.lovisual.hero_menu.options": "Options", + "screen.lovisual.hero_menu.quit": "Quit", + "screen.lovisual.hero_menu.account": "Current account", + "screen.lovisual.hero_menu.sign_in": "Sign in again", + "screen.lovisual.hero_menu.period.0": "Morning", + "screen.lovisual.hero_menu.period.1": "Day", + "screen.lovisual.hero_menu.period.2": "Evening", + "screen.lovisual.hero_menu.period.3": "Night", + "screen.lovisual.hero_menu.disclaimer1": "LoVisual is not affiliated with Mojang.", + "screen.lovisual.hero_menu.disclaimer2": "Not an official Minecraft product.", + "screen.lovisual.hero_menu.version": "LoVisual | Minecraft %s", + "module.voicechat.description": "Simple Voice Chat companion: pulsing indicators above players who are talking, a mute mark over your own head.", + "setting.voicechat.hold_ms": "Hold time (ms)", + "setting.voicechat.max_distance": "Max distance", + "setting.voicechat.indicator_size": "Indicator size", + "setting.voicechat.pulse": "Pulse", + "setting.voicechat.show_self": "Show on yourself", + "setting.voicechat.show_muted": "Muted mark", + "setting.voicechat.color": "Speaking color", + "setting.voicechat.whisper_color": "Whisper color", + "setting.voicechat.self_color": "Your color", + "setting.voicechat.muted_color": "Muted color", + "setting.hud.widgets.option.voicechat": "VoiceChat", + "setting.hud.blur_widgets.option.voicechat": "VoiceChat", + "setting.common.voicechat_scale": "Scale", + "setting.common.voicechat_layout": "Layout", + "setting.common.voicechat_layout.option.split_header": "Split Header", + "setting.common.voicechat_layout.option.unified_divider": "Unified Divider", + "setting.common.voicechat_layout.option.split": "Split", + "setting.common.voicechat_layout.option.unified": "Unified", + "setting.common.voicechat_max_rows": "Max rows", + "setting.common.voicechat_hold_ms": "Hold time (ms)", + "setting.common.voicechat_show_self": "Show yourself", + "setting.common.voicechat_bg_alpha": "Background alpha", + "setting.common.voicechat_bg_effect": "Background effect", + "setting.common.voicechat_bg_effect.option.Auto": "Auto", + "setting.common.voicechat_bg_effect.option.blur": "Blur", + "setting.common.voicechat_bg_effect.option.none": "None", + "setting.common.voicechat_blur_alpha": "Blur alpha", + "hud.voicechat.title": "Voice chat", + "hud.voicechat.not_found": "Simple Voice Chat not found", + "hud.voicechat.whisper": "whisper", + "hud.voicechat.status.talking": "talking", + "hud.voicechat.status.whispering": "whispering", + "hud.voicechat.status.push_to_talk": "push to talk", + "hud.voicechat.status.idle": "listening", + "hud.voicechat.status.muted": "muted", + "hud.voicechat.status.disabled": "mic off", + "hud.voicechat.status.disconnected": "not connected", + "module.pvpcooldowns.description": "Shows PvP item cooldowns on the hotbar and HUD.", + "module.tpssync.description": "Syncs client animations and timings with the server TPS.", + "module.spearassist.description": "Helps aim and time spear attacks.", + "module.projectilepuncher.description": "Highlights and helps intercept incoming projectiles.", + "module.predictions.description": "Draws predicted trajectories of projectiles and landing spots.", + "module.targetesp.description": "Marks your current target with an animated effect.", + "module.damagetint.description": "Tints a hit entity to show damage taken.", + "module.clickgui.description": "Opens the ClickGUI and holds its sound and key settings.", + "module.definetarget.description": "Lets you pick the entity the combat modules should focus on.", + "module.fakeplayer.description": "Spawns a client-side dummy player for testing.", + "module.message_filter.description": "Hides chat messages that match your filter words.", + "module.hitsounds.description": "Plays a custom sound when you hit an entity.", + "module.nosound.description": "Mutes selected game sounds.", + "module.betterminecraft.description": "Quality of life tweaks for vanilla menus, items and tooltips.", + "module.shitesp.description": "Highlights entities through walls with a simple outline.", + "module.autotool.description": "Switches to the best tool for the block you mine.", + "module.clickpearl.description": "Throws an ender pearl with a single key press.", + "module.nointeract.description": "Stops accidental interaction with chosen blocks and entities.", + "module.fovcontrol.description": "Overrides the field of view and its dynamic changes.", + "module.trollface_mask.description": "Draws a meme face over chosen players.", + "module.esp.description": "Shows players, mobs and items through walls.", + "module.dropesp.description": "Highlights dropped items through walls.", + "module.soundesp.description": "Marks where nearby sounds come from.", + "module.lightning_target_esp.description": "Strikes a lightning visual at your target.", + "module.norender.description": "Hides selected effects and entities to improve visibility and FPS.", + "module.nametags.description": "Replaces vanilla nametags with detailed ones showing health and gear.", + "module.tazikhat.description": "Puts a hat model on players.", + "module.hiteffect.description": "Spawns a visual effect when you hit something.", + "module.killeffect.description": "Plays a visual effect where a player dies.", + "module.trails.description": "Leaves a colored trail behind moving entities.", + "module.worldparticles.description": "Adds ambient particles around you.", + "module.worldtweaks.description": "Small world rendering tweaks such as time and weather.", + "module.shadersky.description": "Replaces the sky with a shader based one.", + "module.blockhighlight.description": "Customizes the outline of the block you look at.", + "module.chams.description": "Draws entities through walls with a flat or glowing material.", + "module.reimaginedvisual.description": "Reworks vanilla lighting and surfaces for a softer look.", + "module.cameraclip.description": "Lets the third person camera pass through blocks.", + "module.viewmodel.description": "Changes the position, size and swing of your held items.", + "module.aspectratio.description": "Stretches the picture to a custom aspect ratio.", + "module.freecam.description": "Detaches the camera so you can fly around without moving.", + "module.fullbright.description": "Makes everything fully lit, even in darkness.", + "module.jumpcircles.description": "Draws an expanding shape on the ground where you jump.", + "module.crosshair.description": "Replaces the crosshair with a custom animated one.", + "module.backsword.description": "Shows a sword on your back while holding another item.", + "module.postfx.description": "Applies post processing effects such as bloom and blur.", + "module.totemfx.description": "Customizes the totem of undying pop animation.", + "module.zoom.description": "Zooms the view in while a key is held.", + "module.motionblur.description": "Blurs fast camera movement for a smoother feel.", + "hud.admins.description": "Lists staff members that are currently online.", + "hud.cooldowns.description": "Shows active item cooldowns.", + "hud.dynamic_island.description": "A compact animated bar for notifications and status.", + "hud.hud_notifier.description": "Pop-up notifications for module and client events.", + "hud.inventory.description": "Shows your inventory contents on the HUD.", + "hud.itemizer.description": "Quick overview of important items you carry.", + "hud.keybinds.description": "Lists your active module keybinds.", + "hud.media_player.description": "Shows the track that is playing on your system.", + "hud.module_list.description": "Lists the modules that are enabled right now.", + "hud.potions.description": "Shows active potion effects and their time left.", + "hud.radar.description": "A top-down radar of nearby players and mobs.", + "hud.reach_display.description": "Shows the distance of your last hit.", + "hud.scoreboard.description": "A restyled version of the server scoreboard.", + "hud.swap_tooltip.description": "Shows the name of the item you swap to in the hotbar.", + "hud.tab_list.description": "A restyled player list on Tab.", + "hud.target_hud.description": "Shows health and gear of your current target.", + "hud.triangulator.description": "Helps to locate strongholds with eye of ender throws.", + "hud.vanilla_bar.description": "A restyled experience and status bar.", + "hud.vanilla_buttons.description": "Replaces vanilla buttons with the client's own style.", + "hud.vanilla_health.description": "A restyled health, hunger and armor display.", + "hud.vanilla_hotbar.description": "A restyled hotbar.", + "hud.vanilla_tooltips.description": "Draws item and menu tooltips in the client style. When off, tooltips are fully vanilla.", + "hud.voicechat.description": "Shows who is speaking in Simple Voice Chat and your microphone state.", + "hud.watermark.description": "A small client watermark with basic info.", + "hud.better_chat.description": "A restyled chat with search, history and hover details.", + "hud.timer_indicator.description": "Shows the current timer speed.", + "hud.xyz.description": "Shows your coordinates, with nether conversion.", + "hud.game_time.description": "Shows the in-game time and weather.", + "hud.speed_bps.description": "Shows your movement speed in blocks per second.", + "hud.system_time.description": "Shows the real time of your computer.", + "hud.fps.description": "Shows the current frames per second.", + "hud.memory.description": "Shows memory used by the game.", + "hud.ping.description": "Shows your latency to the server.", + "hud.tps.description": "Shows the server ticks per second.", + "hud.ui_relations_color.description": "Colors used for friends, enemies and other player relations.", + "hud.ui_items_color.description": "Colors used to mark item rarity and special items.", + "hud.armor.description": "Shows your worn armor and its durability.", + "hud.combat_log.description": "Shows a short log of recent combat events.", + "module.ambientparticles.description": "Adds soft ambient particles around you.", + "module.balancer.description": "Balances the speed of other timing based modules.", + "module.combatlog.description": "Records fight events and stats for later review.", + "module.endcrystal.description": "Shows end crystal effects and their blast radius.", + "module.gps.description": "Draws a guiding line to a saved point in the world.", + "module.hitbubbles.description": "Spawns floating bubbles with the damage dealt.", + "module.holdmyitems.description": "Customizes how held items are positioned and animated.", + "module.kineticlyrics.description": "Shows animated song lyrics on screen.", + "module.rain.description": "Customizes rain visuals and sounds.", + "module.spatialgui.description": "Renders the interface as a floating panel in 3D space.", + "module.trinket.description": "Attaches decorative trinkets to your character.", + "module.wet_world.description": "Makes surfaces look wet during rain." } 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 ec844d2..ecfb377 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3,6 +3,11 @@ "clickgui.settings.tab.ui": "UI", "clickgui.settings.tab.themes": "Темы", "clickgui.settings.tab.main_settings": "Основные настройки", + "clickgui.settings.category.image": "Изображение", + "clickgui.settings.category.main_menu": "Главное меню", + "clickgui.settings.category.security": "Безопасность", + "clickgui.settings.category.utility": "Утилиты", + "clickgui.settings.category.miscellanious": "Прочее", "clickgui.settings.tab.configs": "Конфиги", "clickgui.settings.config.type.modules": "Модули", "clickgui.settings.config.type.hud": "Hud", @@ -569,6 +574,20 @@ "setting.targetesp.mode.option.crystals": "Кристаллы", "setting.targetesp.mode.option.blackhole": "Чёрная дыра", "setting.targetesp.mode.option.vortex": "Вихрь", + "setting.targetesp.mode.option.constellation": "Созвездие", + "setting.targetesp.constellation_stars": "Звёзды", + "setting.targetesp.constellation_speed": "Скорость созвездия", + "setting.targetesp.constellation_orbit": "Орбита", + "setting.targetesp.constellation_lines": "Линии", + "setting.targetesp.constellation_point_size": "Размер звезды", + "setting.targetesp.constellation_glow": "Свечение", + "setting.targetesp.mode.option.swords": "Мечи", + "setting.targetesp.swords_speed": "Время оборота (мс)", + "setting.targetesp.swords_radius": "Радиус орбиты", + "setting.targetesp.swords_size": "Размер мечей", + "setting.targetesp.swords_bob": "Покачивание", + "setting.targetesp.swords_glow": "Свечение", + "setting.targetesp.swords_physics": "Пружинная физика", "setting.targetesp.vortex_speed": "Скорость вихря", "setting.targetesp.color": "Цвет", "setting.targetesp.color_mode": "Режим цвета", @@ -594,14 +613,23 @@ "setting.targetesp.esp_amplitude": "ESP амплитуда", "setting.targetesp.capture_spin_speed": "Скорость вращения метки", "setting.jumpcircles.mode": "Режим", - "setting.jumpcircles.mode.option.Default": "По умолчанию", + "setting.jumpcircles.mode.option.Circle": "Круг", + "setting.jumpcircles.mode.option.Ring": "Кольцо", "setting.jumpcircles.mode.option.Bubble": "Пузырь", - "setting.jumpcircles.mode.option.Pulse": "Пульс", - "setting.jumpcircles.ease_out": "Плавное затухание", + "setting.jumpcircles.mode.option.Star": "Звезда", + "setting.jumpcircles.mode.option.Diamond": "Ромб", + "setting.jumpcircles.mode.option.Spiral": "Спираль", + "setting.jumpcircles.mode.option.Hexagon": "Гексагон", + "setting.jumpcircles.mode.option.Wave": "Волна", + "setting.jumpcircles.mode.option.Cross": "Крест", + "setting.jumpcircles.mode.option.Petals": "Лепестки", "setting.jumpcircles.rotate_speed": "Скорость вращения", - "setting.jumpcircles.circle_scale": "Масштаб круга", - "setting.jumpcircles.pulse_max_size": "Макс. размер пульса", - "setting.jumpcircles.pulse_lifetime": "Время жизни пульса", + "setting.jumpcircles.speed": "Скорость", + "setting.jumpcircles.fade_speed": "Скорость исчезновения", + "setting.jumpcircles.thickness": "Толщина", + "setting.jumpcircles.segments": "Сегменты", + "setting.jumpcircles.max_circles": "Макс. кругов", + "setting.jumpcircles.circle_scale": "Радиус", "setting.jumpcircles.only_self": "Только себя", "setting.jumpcircles.depth_test": "Глубина", "setting.jumpcircles.color_mode": "Режим цвета", @@ -642,6 +670,17 @@ "setting.killeffect.mode.option.FallingLava": "Падающая лава", "setting.killeffect.mode.option.EmbersArc": "Искры дугой", "setting.killeffect.mode.option.Lightning": "Молния", + "setting.killeffect.mode.option.Blood": "Кровь", + "setting.killeffect.blood_color": "Цвет крови", + "setting.killeffect.blood_style": "Тип разлёта", + "setting.killeffect.blood_style.option.Burst": "Взрыв", + "setting.killeffect.blood_style.option.Fountain": "Фонтан", + "setting.killeffect.blood_style.option.Ring": "Кольцо", + "setting.killeffect.blood_style.option.Spray": "Веер от удара", + "setting.killeffect.blood_amount": "Количество", + "setting.killeffect.blood_power": "Сила разлёта", + "setting.killeffect.blood_size": "Размер капель", + "setting.killeffect.blood_time": "Время пятен (с)", "setting.killeffect.y_speed": "Скорость по Y", "setting.killeffect.play_sound": "Проигрывать звук", "setting.killeffect.sound_volume": "Громкость звука", @@ -3097,6 +3136,10 @@ "setting.autorespawn.delay": "Задержка", "module.deathcoords.description": "Отправляет в чат координаты вашей последней смерти.", "setting.deathcoords.show_dimension": "Показывать измерение", + "setting.holdmyitems.mode": "Режим", + "setting.holdmyitems.mode.option.Dynamics": "Динамика (Java)", + "setting.holdmyitems.mode.option.Scripts": "Скрипты (Lua)", + "setting.holdmyitems.mode.option.Both": "Оба", "setting.holdmyitems.sway_intensity": "Интенсивность покачивания", "setting.holdmyitems.ease_strength": "Плавность equip", "setting.holdmyitems.offhand": "Применять к оффхенду", @@ -3214,5 +3257,167 @@ "setting.wings.color": "Цвет", "setting.kineticlyrics.time_offset_ms.desc": "Сдвиг тайминга текста относительно слышимого звука (мс). Плюс = текст позже. Позиция плеера опережает динамики (Bluetooth: 0.5–1с+), дефолтный минус это компенсирует. Подстрой через debug, если всё ещё плывёт.", "setting.kineticlyrics.arc_spread.desc": "Насколько широко вокруг обзора могут появляться строки; автоматически ограничивается реальным горизонтальным FOV.", - "command.link.description": "Привязывает клиент к аккаунту LoVisual или проверяет/отвязывает текущую привязку." + "command.link.description": "Привязывает клиент к аккаунту LoVisual или проверяет/отвязывает текущую привязку.", + "setting.main_config.menuLayout": "Стиль главного меню", + "setting.main_config.menuLayout.option.orbs": "Круги", + "setting.main_config.menuLayout.option.hero": "Hero", + "setting.main_config.menuLayout.desc": "Вид главного меню: Круги, это часы с круглыми кнопками; Hero, это приветствие, список кнопок и карточка аккаунта.", + "setting.main_config.menuBg.desc": "Анимированный шейдер за главным меню. Выключено возвращает ванильный экран заголовка.", + "setting.main_config.menuBgUseTheme.desc": "Окрашивать фон меню в цвета текущей темы ClickGUI.", + "setting.main_config.menuClockShowSeconds.desc": "Показывать секунды в часах меню Круги.", + "screen.lovisual.hero_menu.greeting": "РАД ВИДЕТЬ, %s", + "screen.lovisual.hero_menu.title1": "Всё готово,", + "screen.lovisual.hero_menu.title2": "это LoVisual.", + "screen.lovisual.hero_menu.subtitle": "Куда сегодня: на сервер или в свой мир?", + "screen.lovisual.hero_menu.multiplayer": "Сетевая игра", + "screen.lovisual.hero_menu.singleplayer": "Одиночная игра", + "screen.lovisual.hero_menu.accounts": "Аккаунты", + "screen.lovisual.hero_menu.addons": "Аддоны", + "screen.lovisual.hero_menu.options": "Настройки", + "screen.lovisual.hero_menu.quit": "Выход", + "screen.lovisual.hero_menu.account": "Текущий аккаунт", + "screen.lovisual.hero_menu.sign_in": "Войдите снова", + "screen.lovisual.hero_menu.period.0": "Утро", + "screen.lovisual.hero_menu.period.1": "День", + "screen.lovisual.hero_menu.period.2": "Вечер", + "screen.lovisual.hero_menu.period.3": "Ночь", + "screen.lovisual.hero_menu.disclaimer1": "LoVisual не связан с Mojang.", + "screen.lovisual.hero_menu.disclaimer2": "Не является официальным продуктом Minecraft.", + "screen.lovisual.hero_menu.version": "LoVisual | Minecraft %s", + "module.voicechat.description": "Спутник Simple Voice Chat: пульсирующие индикаторы над говорящими игроками и отметка заглушённого микрофона над вами.", + "setting.voicechat.hold_ms": "Время удержания (мс)", + "setting.voicechat.max_distance": "Макс. дистанция", + "setting.voicechat.indicator_size": "Размер индикатора", + "setting.voicechat.pulse": "Пульсация", + "setting.voicechat.show_self": "Показывать на себе", + "setting.voicechat.show_muted": "Отметка заглушения", + "setting.voicechat.color": "Цвет речи", + "setting.voicechat.whisper_color": "Цвет шёпота", + "setting.voicechat.self_color": "Ваш цвет", + "setting.voicechat.muted_color": "Цвет заглушения", + "setting.hud.widgets.option.voicechat": "Голосовой чат", + "setting.hud.blur_widgets.option.voicechat": "Голосовой чат", + "setting.common.voicechat_scale": "Масштаб", + "setting.common.voicechat_layout": "Раскладка", + "setting.common.voicechat_layout.option.split_header": "Раздельная шапка", + "setting.common.voicechat_layout.option.unified_divider": "Единый разделитель", + "setting.common.voicechat_layout.option.split": "Раздельно", + "setting.common.voicechat_layout.option.unified": "Единый", + "setting.common.voicechat_max_rows": "Макс. строк", + "setting.common.voicechat_hold_ms": "Время удержания (мс)", + "setting.common.voicechat_show_self": "Показывать себя", + "setting.common.voicechat_bg_alpha": "Альфа фона", + "setting.common.voicechat_bg_effect": "Эффект фона", + "setting.common.voicechat_bg_effect.option.Auto": "Авто", + "setting.common.voicechat_bg_effect.option.blur": "Блюр", + "setting.common.voicechat_bg_effect.option.none": "Нет", + "setting.common.voicechat_blur_alpha": "Альфа блюра", + "hud.voicechat.title": "Голосовой чат", + "hud.voicechat.not_found": "Simple Voice Chat не найден", + "hud.voicechat.whisper": "шёпот", + "hud.voicechat.status.talking": "говорю", + "hud.voicechat.status.whispering": "шепчу", + "hud.voicechat.status.push_to_talk": "по кнопке", + "hud.voicechat.status.idle": "слушаю", + "hud.voicechat.status.muted": "заглушён", + "hud.voicechat.status.disabled": "микрофон выкл.", + "hud.voicechat.status.disconnected": "нет соединения", + "module.pvpcooldowns.description": "Показывает перезарядки предметов в PvP на хотбаре и HUD.", + "module.tpssync.description": "Подстраивает клиентские анимации и тайминги под TPS сервера.", + "module.spearassist.description": "Помогает целиться и выбирать момент для ударов копьём.", + "module.projectilepuncher.description": "Подсвечивает летящие снаряды и помогает их перехватывать.", + "module.predictions.description": "Рисует предсказанные траектории снарядов и точки падения.", + "module.targetesp.description": "Отмечает вашу текущую цель анимированным эффектом.", + "module.damagetint.description": "Подкрашивает получившую урон сущность.", + "module.clickgui.description": "Открывает ClickGUI и хранит его настройки звуков и бинда.", + "module.definetarget.description": "Позволяет выбрать сущность, на которую ориентируются боевые модули.", + "module.fakeplayer.description": "Создаёт клиентского игрока-манекена для тестов.", + "module.message_filter.description": "Скрывает сообщения чата, подходящие под ваши слова-фильтры.", + "module.hitsounds.description": "Проигрывает свой звук при ударе по сущности.", + "module.nosound.description": "Отключает выбранные звуки игры.", + "module.betterminecraft.description": "Небольшие улучшения ванильных меню, предметов и подсказок.", + "module.shitesp.description": "Подсвечивает сущности сквозь стены простым контуром.", + "module.autotool.description": "Переключается на лучший инструмент для добываемого блока.", + "module.clickpearl.description": "Бросает жемчуг Края по нажатию одной клавиши.", + "module.nointeract.description": "Не даёт случайно взаимодействовать с выбранными блоками и сущностями.", + "module.fovcontrol.description": "Переопределяет угол обзора и его динамические изменения.", + "module.trollface_mask.description": "Рисует мемное лицо поверх выбранных игроков.", + "module.esp.description": "Показывает игроков, мобов и предметы сквозь стены.", + "module.dropesp.description": "Подсвечивает выпавшие предметы сквозь стены.", + "module.soundesp.description": "Отмечает, откуда доносятся ближайшие звуки.", + "module.lightning_target_esp.description": "Рисует эффект молнии в месте цели.", + "module.norender.description": "Скрывает выбранные эффекты и сущности для обзора и FPS.", + "module.nametags.description": "Заменяет ванильные ники подробными: здоровье и экипировка.", + "module.tazikhat.description": "Надевает на игроков модель шляпы.", + "module.hiteffect.description": "Создаёт визуальный эффект при попадании.", + "module.killeffect.description": "Проигрывает визуальный эффект там, где умирает игрок.", + "module.trails.description": "Оставляет цветной след за движущимися сущностями.", + "module.worldparticles.description": "Добавляет вокруг вас атмосферные частицы.", + "module.worldtweaks.description": "Небольшие правки отрисовки мира: время, погода.", + "module.shadersky.description": "Заменяет небо шейдерным.", + "module.blockhighlight.description": "Настраивает контур блока, на который вы смотрите.", + "module.chams.description": "Рисует сущности сквозь стены плоским или светящимся материалом.", + "module.reimaginedvisual.description": "Переосмысляет ванильное освещение и поверхности для мягкой картинки.", + "module.cameraclip.description": "Позволяет камере от третьего лица проходить сквозь блоки.", + "module.viewmodel.description": "Меняет положение, размер и замах предметов в руке.", + "module.aspectratio.description": "Растягивает картинку до выбранного соотношения сторон.", + "module.freecam.description": "Отсоединяет камеру, чтобы летать, не двигая игрока.", + "module.fullbright.description": "Освещает всё полностью, даже в темноте.", + "module.jumpcircles.description": "Рисует расходящуюся фигуру на земле там, где вы прыгаете.", + "module.crosshair.description": "Заменяет прицел собственным анимированным.", + "module.backsword.description": "Показывает меч за спиной, пока в руке другой предмет.", + "module.postfx.description": "Применяет эффекты постобработки: свечение, размытие.", + "module.totemfx.description": "Настраивает анимацию срабатывания тотема бессмертия.", + "module.zoom.description": "Приближает картинку, пока удерживается клавиша.", + "module.motionblur.description": "Размывает быстрые движения камеры для плавности.", + "hud.admins.description": "Список администрации, которая сейчас в сети.", + "hud.cooldowns.description": "Показывает активные перезарядки предметов.", + "hud.dynamic_island.description": "Компактная анимированная панель уведомлений и статуса.", + "hud.hud_notifier.description": "Всплывающие уведомления о событиях модулей и клиента.", + "hud.inventory.description": "Показывает содержимое инвентаря на HUD.", + "hud.itemizer.description": "Быстрый обзор важных предметов в инвентаре.", + "hud.keybinds.description": "Список активных биндов модулей.", + "hud.media_player.description": "Показывает трек, который играет в системе.", + "hud.module_list.description": "Список включённых сейчас модулей.", + "hud.potions.description": "Показывает активные эффекты зелий и оставшееся время.", + "hud.radar.description": "Радар с видом сверху на ближайших игроков и мобов.", + "hud.reach_display.description": "Показывает дистанцию вашего последнего удара.", + "hud.scoreboard.description": "Переработанное оформление скорборда сервера.", + "hud.swap_tooltip.description": "Показывает название предмета при смене слота хотбара.", + "hud.tab_list.description": "Переработанный список игроков по Tab.", + "hud.target_hud.description": "Показывает здоровье и экипировку текущей цели.", + "hud.triangulator.description": "Помогает найти крепость по броскам ока Эндера.", + "hud.vanilla_bar.description": "Переработанная полоса опыта и статуса.", + "hud.vanilla_buttons.description": "Заменяет ванильные кнопки стилем клиента.", + "hud.vanilla_health.description": "Переработанное отображение здоровья, голода и брони.", + "hud.vanilla_hotbar.description": "Переработанный хотбар.", + "hud.vanilla_tooltips.description": "Рисует подсказки предметов и меню в стиле клиента. Когда выключено, подсказки полностью ванильные.", + "hud.voicechat.description": "Показывает, кто говорит в Simple Voice Chat, и состояние вашего микрофона.", + "hud.watermark.description": "Небольшая плашка клиента с базовой информацией.", + "hud.better_chat.description": "Переработанный чат с поиском, историей и подсказками при наведении.", + "hud.timer_indicator.description": "Показывает текущую скорость таймера.", + "hud.xyz.description": "Показывает ваши координаты, в том числе пересчёт для Незера.", + "hud.game_time.description": "Показывает игровое время и погоду.", + "hud.speed_bps.description": "Показывает вашу скорость в блоках в секунду.", + "hud.system_time.description": "Показывает реальное время вашего компьютера.", + "hud.fps.description": "Показывает текущий FPS.", + "hud.memory.description": "Показывает память, занятую игрой.", + "hud.ping.description": "Показывает вашу задержку до сервера.", + "hud.tps.description": "Показывает тики сервера в секунду (TPS).", + "hud.ui_relations_color.description": "Цвета для друзей, врагов и других отношений с игроками.", + "hud.ui_items_color.description": "Цвета для редкости предметов и особых предметов.", + "hud.armor.description": "Показывает надетую броню и её прочность.", + "hud.combat_log.description": "Показывает короткий журнал последних боевых событий.", + "module.ambientparticles.description": "Добавляет вокруг вас мягкие атмосферные частицы.", + "module.balancer.description": "Балансирует скорость других модулей, работающих по таймингам.", + "module.combatlog.description": "Записывает события и статистику боёв для просмотра.", + "module.endcrystal.description": "Показывает эффекты кристаллов Края и радиус их взрыва.", + "module.gps.description": "Рисует линию-ориентир к сохранённой точке в мире.", + "module.hitbubbles.description": "Создаёт всплывающие пузырьки с нанесённым уроном.", + "module.holdmyitems.description": "Настраивает положение и анимацию предметов в руках.", + "module.kineticlyrics.description": "Показывает анимированный текст песни на экране.", + "module.rain.description": "Настраивает вид и звук дождя.", + "module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.", + "module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.", + "module.wet_world.description": "Делает поверхности мокрыми во время дождя." } diff --git a/mod/src/main/resources/assets/lovisual/textures/particles/sword.png b/mod/src/main/resources/assets/lovisual/textures/particles/sword.png new file mode 100644 index 0000000000000000000000000000000000000000..d679f78e4b65b326f94b6dbbae0c81cf35500dc8 GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^3P9|@!3HF&`%2dVse7I-jv*Cu-riK?KI9;A?Bo9< z{F^h(I&LZ~O4u}0M{V)Nj8DxHJZ^#$6Sr7y@ZaCMj9GHdZtK?M4>q|uGW)A{7~J#b z@R_wOZvOeoI$x)2nJrBJMANt4{8+P*CBaOVv0%47gT*`}2D8_q5AJz7D1z?Y!4;uHk#jZ4+Y&e})P%R)*=H89&UQ z<(>~V=fLl4XH2*5G+q5iUL$???fP2#H{bVMTOH55&w7p57q%(puXh-x^VLl%1p1W0 M)78&qol`;+034fZRsaA1 literal 0 HcmV?d00001 diff --git a/mod/src/main/resources/fabric.mod.json b/mod/src/main/resources/fabric.mod.json index 5e96080..8fd9ffa 100644 --- a/mod/src/main/resources/fabric.mod.json +++ b/mod/src/main/resources/fabric.mod.json @@ -15,6 +15,9 @@ "entrypoints": { "client": [ "dev.loki.lovisual.LoVisual" + ], + "voicechat": [ + "dev.loki.lovisual.compat.voicechat.LoVisualVoicechatPlugin" ] }, "mixins": [ @@ -28,6 +31,7 @@ "sodium": "*", "iris": "*", "immediatelyfast": "*", - "viafabricplus": "*" + "viafabricplus": "*", + "voicechat": "*" } } diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json index 00a9cbf..bdfcaa8 100644 --- a/mod/src/main/resources/lovisual.mixins.json +++ b/mod/src/main/resources/lovisual.mixins.json @@ -92,6 +92,7 @@ "entity.world.FireworkRocketEntityMixin", "gui.chat.ChatComponentMixin", "gui.chat.ChatScreenMixin", + "gui.chat.CommandSuggestionsMixin", "gui.chat.ContextualBarMixin", "gui.core.AbstractContainerScreenMixin", "gui.core.AbstractSelectionListMixin", diff --git a/mod/src/test/java/dev/loki/lovisual/compat/voicechat/VoiceStateTest.java b/mod/src/test/java/dev/loki/lovisual/compat/voicechat/VoiceStateTest.java new file mode 100644 index 0000000..da643c4 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/compat/voicechat/VoiceStateTest.java @@ -0,0 +1,112 @@ +package dev.loki.lovisual.compat.voicechat; + +import dev.loki.lovisual.compat.voicechat.VoiceState.MicStatus; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class VoiceStateTest { + private final AtomicLong now = new AtomicLong(1_000); + private final VoiceState state = new VoiceState(now::get); + private final UUID a = UUID.randomUUID(); + private final UUID b = UUID.randomUUID(); + + @Test + void speakingExpiresAfterWindow() { + state.markSpeaking(a, false); + assertTrue(state.isSpeaking(a, 300)); + now.addAndGet(301); + assertFalse(state.isSpeaking(a, 300)); + assertFalse(state.isSpeaking(b, 300)); + } + + @Test + void whisperFlagFollowsLastPacket() { + state.markSpeaking(a, true); + assertTrue(state.isWhispering(a, 300)); + state.markSpeaking(a, false); + assertFalse(state.isWhispering(a, 300)); + } + + @Test + void levelFadesLinearly() { + state.markSpeaking(a, false); + assertEquals(1f, state.level(a, 400), 1e-6); + now.addAndGet(100); + assertEquals(0.75f, state.level(a, 400), 1e-6); + now.addAndGet(400); + assertEquals(0f, state.level(a, 400), 1e-6); + assertEquals(0f, state.level(b, 400), 1e-6); + } + + @Test + void speakersAreNewestFirstAndCapped() { + state.markSpeaking(a, false); + now.addAndGet(50); + state.markSpeaking(b, true); + List all = state.speakers(500, 8); + assertEquals(List.of(b, a), all.stream().map(VoiceState.Speaker::id).toList()); + assertTrue(all.get(0).whisper()); + assertEquals(1, state.speakers(500, 1).size()); + assertEquals(b, state.speakers(500, 1).get(0).id()); + now.addAndGet(1_000); + assertTrue(state.speakers(500, 8).isEmpty()); + } + + @Test + void micStatusPriority() { + assertEquals(MicStatus.DISCONNECTED, state.micStatus(300)); + state.setConnected(true); + assertEquals(MicStatus.IDLE, state.micStatus(300)); + state.setPushToTalk(true); + assertEquals(MicStatus.PUSH_TO_TALK, state.micStatus(300)); + state.markSelfSpeaking(false); + assertEquals(MicStatus.TALKING, state.micStatus(300)); + state.markSelfSpeaking(true); + assertEquals(MicStatus.WHISPERING, state.micStatus(300)); + assertTrue(state.isSelfWhispering(300)); + state.setMuted(true); + assertEquals(MicStatus.MUTED, state.micStatus(300)); + state.setDisabled(true); + assertEquals(MicStatus.DISABLED, state.micStatus(300)); + state.setConnected(false); + assertEquals(MicStatus.DISCONNECTED, state.micStatus(300)); + } + + @Test + void disconnectAndClearDropEverything() { + state.setConnected(true); + state.markSpeaking(a, false); + state.markSelfSpeaking(false); + state.clear(); + assertFalse(state.isSpeaking(a, 300)); + assertEquals(0f, state.selfLevel(300), 1e-6); + state.markSpeaking(a, false); + state.setConnected(false); + assertTrue(state.speakers(300, 8).isEmpty()); + } + + @Test + void concurrentWritersDoNotLoseSpeakers() throws Exception { + int n = 8; + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch done = new CountDownLatch(n); + for (int i = 0; i < n; i++) { + pool.submit(() -> { + for (int k = 0; k < 200; k++) state.markSpeaking(UUID.randomUUID(), k % 2 == 0); + done.countDown(); + }); + } + assertTrue(done.await(5, TimeUnit.SECONDS)); + pool.shutdown(); + assertEquals(n * 200, state.speakers(1_000, Integer.MAX_VALUE).size()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/command/suggest/SuggestionPlanTest.java b/mod/src/test/java/dev/loki/lovisual/features/command/suggest/SuggestionPlanTest.java new file mode 100644 index 0000000..abcffa1 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/command/suggest/SuggestionPlanTest.java @@ -0,0 +1,41 @@ +package dev.loki.lovisual.features.command.suggest; + +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.suggestion.Suggestion; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SuggestionPlanTest { + private static final StringRange RANGE = StringRange.between(1, 3); + + @Test + void realCompletionsAreKeptInOrder() { + SuggestionPlan plan = SuggestionPlan.of(List.of(new Suggestion(RANGE, "toggle"), new Suggestion(RANGE, "t"))); + assertEquals(2, plan.suggestions().getList().size()); + assertEquals(RANGE, plan.suggestions().getRange()); + assertNull(plan.ghostHint()); + } + + @Test + void usageHintBecomesGhostTextNotACompletion() { + SuggestionPlan plan = SuggestionPlan.of(List.of(new Suggestion(RANGE, "%toggle "))); + assertNull(plan.suggestions()); + assertEquals("", plan.ghostHint()); + } + + @Test + void mixedListSeparatesHintFromChoices() { + SuggestionPlan plan = SuggestionPlan.of(List.of(new Suggestion(RANGE, "%bind "), new Suggestion(RANGE, "esp"))); + assertEquals(1, plan.suggestions().getList().size()); + assertEquals("", plan.ghostHint()); + } + + @Test + void emptyOrNullInputGivesNothing() { + assertNull(SuggestionPlan.of(null).suggestions()); + assertNull(SuggestionPlan.of(List.of()).ghostHint()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLinesTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLinesTest.java new file mode 100644 index 0000000..4ea41f1 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/DescriptionLinesTest.java @@ -0,0 +1,39 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DescriptionLinesTest { + /** One unit per character keeps the arithmetic obvious. */ + private static double width(String s) { + return s.length(); + } + + @Test + void wrapsOnWordBoundaries() { + assertEquals(List.of("aaa bbb", "ccc"), DescriptionLines.wrap("aaa bbb ccc", 8, DescriptionLinesTest::width, 5)); + } + + @Test + void oversizedWordKeepsItsOwnLine() { + assertEquals(List.of("a", "bbbbbbbbbb", "c"), DescriptionLines.wrap("a bbbbbbbbbb c", 4, DescriptionLinesTest::width, 5)); + } + + @Test + void overflowIsCutWithEllipsis() { + List lines = DescriptionLines.wrap("one two three four five six", 7, DescriptionLinesTest::width, 2); + assertEquals(2, lines.size()); + assertTrue(lines.get(1).endsWith("...")); + assertTrue(lines.get(1).length() <= 7); + } + + @Test + void blankInputGivesNoLines() { + assertTrue(DescriptionLines.wrap(" ", 10, DescriptionLinesTest::width, 3).isEmpty()); + assertTrue(DescriptionLines.wrap(null, 10, DescriptionLinesTest::width, 3).isEmpty()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/TooltipColorsTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/TooltipColorsTest.java new file mode 100644 index 0000000..7ac64b8 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/TooltipColorsTest.java @@ -0,0 +1,21 @@ +package dev.loki.lovisual.features.gui.hud.nondraggable.impl.tooltips; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TooltipColorsTest { + private static final int ACCENT = 0xFFE69A3C; + + @Test + void vanillaBluesBecomeTheAccent() { + assertEquals(ACCENT, TooltipColors.readable(0xFF5555FF, ACCENT)); + assertEquals(ACCENT, TooltipColors.readable(0xFF0000AA, ACCENT)); + } + + @Test + void otherColorsAreKept() { + assertEquals(0xFFFFAA00, TooltipColors.readable(0xFFFFAA00, ACCENT)); + assertEquals(0xFFAAAAAA, TooltipColors.readable(0xFFAAAAAA, ACCENT)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLogicTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLogicTest.java new file mode 100644 index 0000000..1aa4e55 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/mainmenu/hero/logic/HeroLogicTest.java @@ -0,0 +1,87 @@ +package dev.loki.lovisual.features.gui.mainmenu.hero.logic; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class HeroLogicTest { + private static final TextMetrics METRICS = px -> px * 1.35f; + + private static HeroLayout.Layout layout(float w, float h) { + return HeroLayout.compute(w, h, 6, 0f, METRICS); + } + + @Test + void periodsCoverTheWholeDay() { + assertEquals(DayCycle.NIGHT, DayCycle.period(3)); + assertEquals(DayCycle.MORNING, DayCycle.period(5)); + assertEquals(DayCycle.DAY, DayCycle.period(12)); + assertEquals(DayCycle.EVENING, DayCycle.period(21)); + assertEquals(DayCycle.NIGHT, DayCycle.period(22)); + assertEquals(DayCycle.NIGHT, DayCycle.period(23)); + } + + @Test + void thumbStaysInsideBar() { + assertEquals(105f, DayCycle.thumbX(100f, 200f, 10f, 0f), 1e-4); + assertEquals(295f, DayCycle.thumbX(100f, 200f, 10f, 1f), 1e-4); + assertEquals(200f, DayCycle.thumbX(100f, 200f, 10f, DayCycle.progress(12, 0)), 1e-4); + assertEquals(105f, DayCycle.thumbX(100f, 200f, 10f, -3f), 1e-4); + } + + @Test + void buttonsAreStackedWithoutOverlap() { + HeroLayout.Layout l = layout(1920f, 1080f); + assertEquals(HeroLayout.BUTTON_COUNT, l.buttons().length); + for (int i = 0; i < HeroLayout.ADDONS; i++) { + assertTrue(l.buttons()[i].y() + l.buttons()[i].h() < l.buttons()[i + 1].y()); + } + Rect o = l.buttons()[HeroLayout.OPTIONS]; + Rect q = l.buttons()[HeroLayout.QUIT]; + assertTrue(o.x() + o.w() < q.x()); + assertEquals(l.buttons()[0].x() + l.buttons()[0].w(), q.x() + q.w(), 1e-3); + } + + @Test + void hitTestFindsButtonsAndDots() { + HeroLayout.Layout l = layout(1920f, 1080f); + for (int i = 0; i < HeroLayout.BUTTON_COUNT; i++) { + Rect r = l.buttons()[i]; + assertEquals(i, HeroLayout.buttonAt(l, r.x() + r.w() / 2, r.y() + r.h() / 2)); + } + assertEquals(-1, HeroLayout.buttonAt(l, 0f, 0f)); + for (int i = 0; i < 6; i++) { + assertEquals(i, HeroLayout.dotAt(l, 6, l.dotsStartX() + i * l.dotSpacing(), l.dotY())); + } + assertEquals(-1, HeroLayout.dotAt(l, 6, 0f, 0f)); + } + + @Test + void dotsAndWidgetFitInsideViewport() { + HeroLayout.Layout l = layout(1920f, 1080f); + assertTrue(l.dotsStartX() - l.dotRadius() >= l.widget().x()); + assertTrue(l.widget().x() + l.widget().w() <= 1920f); + assertEquals(12f / 12f, HeroLayout.font(12f), 1e-6); + } + + @Test + void textBlocksNeverOverlapAtAnyResolution() { + float[][] sizes = {{1920f, 1080f}, {1280f, 720f}, {854f, 480f}, {3840f, 2160f}, {1024f, 600f}}; + for (float[] s : sizes) { + HeroLayout.Layout l = layout(s[0], s[1]); + float titleH = METRICS.height(l.sizes().title()); + assertTrue(l.tagY() + METRICS.height(l.sizes().tag()) <= l.titleY1() + 1e-3, "tag"); + assertTrue(l.titleY1() + titleH <= l.titleY2() + 1e-3, "title lines"); + assertTrue(l.titleY2() + titleH <= l.subY() + 1e-3, "title/sub"); + assertTrue(l.subY() + METRICS.height(l.sizes().sub()) <= l.buttons()[0].y() + 1e-3, "sub/buttons"); + Rect last = l.buttons()[HeroLayout.QUIT]; + assertTrue(last.y() + last.h() <= l.footerY() + 1e-3, "buttons/footer " + s[1]); + assertTrue(l.account().y() + l.avatarSize() <= l.tagY(), "account/tag " + s[1]); + } + } + + @Test + void layoutScalesDownWhenTheViewportIsShort() { + assertTrue(layout(1024f, 400f).k() < layout(1920f, 1080f).k()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayoutTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayoutTest.java new file mode 100644 index 0000000..6a17f0f --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/constellation/ConstellationLayoutTest.java @@ -0,0 +1,36 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.constellation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ConstellationLayoutTest { + @Test + void starsStayWithinOrbitAndAroundBodyHeight() { + int n = 12; + double[] p = new double[n * 3]; + ConstellationLayout.positions(p, n, 0.6f, 1.8f, 1.1f, 3.7, 1.0f); + double maxR = 1.1 * 0.6 * 1.0; + for (int i = 0; i < n; i++) { + assertTrue(Math.hypot(p[i * 3], p[i * 3 + 2]) <= maxR + 1e-9); + assertEquals(0.9, p[i * 3 + 1], 0.9); + } + } + + @Test + void spinMovesStars() { + double[] a = new double[9]; + double[] b = new double[9]; + ConstellationLayout.positions(a, 3, 0.6f, 1.8f, 1.0f, 0.0, 1.0f); + ConstellationLayout.positions(b, 3, 0.6f, 1.8f, 1.0f, 1.0, 1.0f); + assertNotEquals(a[0], b[0]); + } + + @Test + void pulseStaysInRange() { + for (int s = 0; s < 9; s++) { + float v = ConstellationLayout.pulse(12.3, s, 9); + assertTrue(v >= 0.5f && v <= 1.0f); + } + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysicsTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysicsTest.java new file mode 100644 index 0000000..384e1fc --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/combat/targetesp/sword/SwordPhysicsTest.java @@ -0,0 +1,57 @@ +package dev.loki.lovisual.features.module.modules.combat.targetesp.sword; + +import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.SwordPhysics.Frame; +import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.SwordPhysics.Sword; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class SwordPhysicsTest { + private static Sword[] swords() { + Sword[] s = new Sword[SwordPhysics.COUNT]; + for (int i = 0; i < s.length; i++) s[i] = new Sword(); + return s; + } + + private static Frame frame(boolean kick, boolean physics) { + return new Frame(0, 0, 1, 1.0, 0, 0, 0, 1f, kick, physics, 0); + } + + @Test + void placedSwordsSitOnTheOrbit() { + Sword[] s = swords(); + SwordPhysics.place(s, 5, -3, 1, 2.0, 0.5); + for (Sword sw : s) assertEquals(2.0, Math.hypot(sw.x - 5, sw.z + 3), 1e-9); + } + + @Test + void withoutPhysicsSwordsSnapToTheirSlot() { + Sword[] s = swords(); + s[0].x = 100; + SwordPhysics.step(s, frame(false, false), new Random(1)); + assertEquals(1.0, s[0].x, 1e-9); + assertEquals(0f, s[0].roll); + } + + @Test + void springPullsADisplacedSwordBackAndSettles() { + Sword[] s = swords(); + SwordPhysics.place(s, 0, 0, 1, 1.0, 0); + s[0].x = 3; + Random rnd = new Random(2); + for (int i = 0; i < 300; i++) SwordPhysics.step(s, frame(false, true), rnd); + assertEquals(1.0, s[0].x, 0.01); + assertEquals(0.0, s[0].vx, 0.01); + } + + @Test + void hitKicksSwordsOutwardsAndUp() { + Sword[] s = swords(); + SwordPhysics.place(s, 0, 0, 1, 1.0, 0); + SwordPhysics.step(s, frame(true, true), new Random(3)); + assertTrue(s[0].vx > 0.05); + assertTrue(s[0].vy > 0.0); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulseTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulseTest.java new file mode 100644 index 0000000..a2a67b8 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/voice/VoiceIndicatorPulseTest.java @@ -0,0 +1,28 @@ +package dev.loki.lovisual.features.module.modules.misc.voice; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class VoiceIndicatorPulseTest { + @Test + void ringsStayInRangeAndExpandWhileFading() { + for (int i = 0; i < VoiceIndicatorPulse.RINGS; i++) { + for (double t = 0; t < 5; t += 0.07) { + VoiceIndicatorPulse.Ring r = VoiceIndicatorPulse.ring(t, i, true); + assertTrue(r.alpha() >= 0f && r.alpha() <= 1f); + assertTrue(r.radius() >= 0.45f && r.radius() <= 1.2f); + } + } + VoiceIndicatorPulse.Ring early = VoiceIndicatorPulse.ring(0.1, 0, true); + VoiceIndicatorPulse.Ring late = VoiceIndicatorPulse.ring(0.9, 0, true); + assertTrue(late.radius() > early.radius()); + assertTrue(late.alpha() < early.alpha()); + } + + @Test + void staticRingsDoNotAnimate() { + assertEquals(VoiceIndicatorPulse.ring(0.0, 1, false), VoiceIndicatorPulse.ring(7.3, 1, false)); + assertTrue(VoiceIndicatorPulse.ring(0.0, 0, false).alpha() > 0f); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysicsTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysicsTest.java new file mode 100644 index 0000000..2ae7f5d --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/blood/BloodPhysicsTest.java @@ -0,0 +1,95 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood; + +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat; +import net.minecraft.world.phys.Vec3; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class BloodPhysicsTest { + private static final BloodPhysics.Surface FLOOR = new BloodPhysics.Surface() { + @Override + public BloodPhysics.Hit cast(Vec3 from, Vec3 to) { + if (from.y > 0 && to.y <= 0) return new BloodPhysics.Hit(new Vec3(to.x, 0, to.z), 0, 1, 0); + return null; + } + + @Override + public float light(Vec3 pos) { + return 1.0f; + } + }; + + @Test + void burstSpawnsRequestedCountNearCenter() { + List out = new ArrayList<>(); + BloodPhysics.burst(out, new Vec3(0, 1, 0), 0.6f, 1.8f, 40, 1.0f, 1.0f, 0xFF8A0303, + BloodPhysics.Style.BURST, new Vec3(1, 0, 0), new Random(1)); + assertEquals(40, out.size()); + for (Drop d : out) { + assertTrue(Math.abs(d.pos.x) <= 0.3 && Math.abs(d.pos.y - 1) <= 0.45); + assertEquals(0xFF, d.color >>> 24); + } + } + + @Test + void stylesShapeTheEruption() { + Vec3 away = new Vec3(1, 0, 0); + for (BloodPhysics.Style style : BloodPhysics.Style.values()) { + List out = new ArrayList<>(); + BloodPhysics.burst(out, Vec3.ZERO, 0.6f, 1.8f, 60, 1.0f, 1.0f, 0xFF8A0303, style, away, new Random(7)); + double upAvg = out.stream().mapToDouble(d -> d.vel.y).average().orElse(0); + double awayAvg = out.stream().mapToDouble(d -> d.vel.x).average().orElse(0); + switch (style) { + case FOUNTAIN -> assertTrue(upAvg > 0.4); + case RING -> assertTrue(upAvg < 0.1); + case SPRAY -> assertTrue(awayAvg > 0.1); + default -> assertTrue(upAvg > 0.1 && upAvg < 0.4); + } + } + assertEquals(BloodPhysics.Style.SPRAY, BloodPhysics.Style.of("spray")); + assertEquals(BloodPhysics.Style.BURST, BloodPhysics.Style.of("nope")); + } + + @Test + void dropFallsAndLeavesSplatOnFloor() { + Drop d = new Drop(new Vec3(0, 1, 0), new Vec3(0, 0, 0), 0.05f, 0xFF8A0303, 1.0f, false); + List splats = new ArrayList<>(); + List children = new ArrayList<>(); + int ticks = 0; + while (BloodPhysics.step(d, FLOOR, splats, children, 0L, new Random(2))) { + assertTrue(++ticks < BloodPhysics.MAX_FLIGHT_TICKS); + } + assertEquals(1, splats.size()); + assertEquals(0.0, splats.get(0).pos().y, 1e-9); + } + + @Test + void childDropsDoNotSplitAgain() { + Drop d = new Drop(new Vec3(0, 0.1, 0), new Vec3(0, -1, 0), 0.1f, 0xFF8A0303, 1.0f, true); + List children = new ArrayList<>(); + BloodPhysics.step(d, FLOOR, new ArrayList<>(), children, 0L, new Random(3)); + assertTrue(children.isEmpty()); + } + + @Test + void dropExpiresInOpenAir() { + Drop d = new Drop(new Vec3(0, 1000, 0), new Vec3(0, 0, 0), 0.05f, 0xFF8A0303, 1.0f, false); + boolean alive = true; + for (int i = 0; i <= BloodPhysics.MAX_FLIGHT_TICKS && alive; i++) { + alive = BloodPhysics.step(d, FLOOR, new ArrayList<>(), new ArrayList<>(), 0L, new Random(4)); + } + assertFalse(alive); + } + + @Test + void shadeKeepsAlphaAndClamps() { + assertEquals(0x80FF0000, BloodGeometry.shade(0x80800000, 2.0f)); + assertEquals(0xCC, BloodGeometry.withAlpha(0xFF112233, 0xCC) >>> 24); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/LuaSandboxTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/LuaSandboxTest.java new file mode 100644 index 0000000..18c1b11 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/LuaSandboxTest.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua; + +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.Easings; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaPreprocessor; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaSandbox; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.ScriptSlot; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LuaSandboxTest { + + private static ScriptSlot slot(LuaSandbox sb, String src) { + ScriptSlot s = new ScriptSlot("t", src, sb); + sb.globals().set("registry", s.registryTable()); + return s; + } + + @Test + void preprocessorTurnsGlobalIntoRegistryLocal() { + String out = LuaPreprocessor.process("global.x = 5;\nx = x + 1"); + assertTrue(out.contains("local x = registry:getOrDefault('x', 5)")); + assertTrue(out.endsWith("registry:put('x', x)")); + } + + @Test + void registryPersistsBetweenRuns() { + LuaSandbox sb = new LuaSandbox(); + ScriptSlot s = slot(sb, "global.n = 0\nn = n + 1\nresult = n"); + s.run(); + s.run(); + s.run(); + assertTrue(s.canRun()); + assertEquals(3, sb.globals().get("result").toint()); + } + + @Test + void dangerousLibrariesAreAbsent() { + LuaSandbox sb = new LuaSandbox(); + for (String name : new String[]{"io", "os", "luajava", "require", "dofile", "loadfile", "load", "debug", "package"}) { + assertTrue(sb.globals().get(name).isnil(), name); + } + } + + @Test + void infiniteLoopIsStopped() { + LuaSandbox sb = new LuaSandbox(); + ScriptSlot s = slot(sb, "while true do end"); + s.run(); + assertFalse(s.canRun()); + assertTrue(s.error().contains("too slow"), s.error()); + } + + @Test + void errorDisablesOnlyThatScript() { + LuaSandbox sb = new LuaSandbox(); + ScriptSlot bad = slot(sb, "error('boom')"); + ScriptSlot good = slot(sb, "ok = 1"); + bad.run(); + good.run(); + assertFalse(bad.canRun()); + assertTrue(good.canRun()); + } + + @Test + void syntaxErrorIsReportedNotThrown() { + ScriptSlot s = slot(new LuaSandbox(), "if then"); + assertFalse(s.canRun()); + assertNotNull(s.error()); + } + + @Test + void easingsHitEndpoints() { + for (var e : Easings.all().entrySet()) { + assertEquals(0.0, e.getValue().applyAsDouble(0.0), 1e-6, e.getKey()); + assertEquals(1.0, e.getValue().applyAsDouble(1.0), 1e-6, e.getKey()); + } + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimationTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimationTest.java new file mode 100644 index 0000000..4332956 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/CircleAnimationTest.java @@ -0,0 +1,56 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CircleAnimationTest { + @Test + void radiusStartsAtZeroAndNeverExceedsMax() { + assertEquals(0f, CircleAnimation.radius(0f, 3f), 1e-6f); + for (int i = 0; i <= 200; i++) { + float r = CircleAnimation.radius(i / 200f, 3f); + assertTrue(r >= 0f && r <= 3f, "radius " + r); + } + } + + @Test + void easeOutCubicIsFastFirstAndClamped() { + assertEquals(0f, CircleAnimation.easeOutCubic(0f), 1e-6f); + assertEquals(1f, CircleAnimation.easeOutCubic(1f), 1e-6f); + assertTrue(CircleAnimation.easeOutCubic(0.5f) > 0.5f); + assertEquals(1f, CircleAnimation.easeOutCubic(7f), 1e-6f); + } + + @Test + void alphaStaysInUnitRangeAndFadesToZero() { + for (float fade : new float[]{0.5f, 1f, 3f}) { + for (int i = 0; i <= 200; i++) { + float a = CircleAnimation.alpha(i / 200f, fade); + assertTrue(a >= 0f && a <= 1f, "alpha " + a); + } + assertEquals(0f, CircleAnimation.alpha(0f, fade), 1e-6f); + assertEquals(0f, CircleAnimation.alpha(1f, fade), 1e-6f); + } + } + + @Test + void higherFadeSpeedKeepsCircleBrighterMidLife() { + assertTrue(CircleAnimation.alpha(0.5f, 3f) > CircleAnimation.alpha(0.5f, 0.5f)); + } + + @Test + void lifetimeShrinksWithSpeedAndProgressIsClamped() { + assertTrue(CircleAnimation.lifetimeMs(2f) < CircleAnimation.lifetimeMs(1f)); + assertEquals(0f, CircleAnimation.progress(-5f, 1000f)); + assertEquals(1f, CircleAnimation.progress(5000f, 1000f)); + } + + @Test + void angleGrowsWithTimeAndSwaysWithProgress() { + assertTrue(CircleAnimation.angle(2f, 1f, 0f) > CircleAnimation.angle(1f, 1f, 0f)); + assertEquals(0f, CircleAnimation.angle(0f, 3f, 0f), 1e-6f); + assertTrue(Math.abs(CircleAnimation.angle(0f, 0f, 0.125f)) > 0f); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometryTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometryTest.java new file mode 100644 index 0000000..f645a82 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/player/jumpcircles/shape/ShapeGeometryTest.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ShapeGeometryTest { + private final ShapeMesh mesh = new ShapeMesh(); + + @Test + void everyShapeStaysInsideUnitCircle() { + for (Shape shape : Shape.values()) { + for (float phase : new float[]{0f, 1.3f, 4.7f}) { + ShapeGeometry.build(shape, mesh, 32, 0.5f, phase); + for (int i = 0; i < mesh.vertexCount(); i++) { + double r = Math.hypot(mesh.x(i), mesh.z(i)); + assertTrue(r <= 1.0001, shape + " vertex " + i + " radius " + r); + } + } + } + } + + @Test + void trianglesReferenceExistingVerticesAndAttributesAreNormalised() { + for (Shape shape : Shape.values()) { + ShapeGeometry.build(shape, mesh, 24, 0.25f, 0.5f); + assertTrue(mesh.indexCount() > 0 && mesh.indexCount() % 3 == 0, shape.name()); + for (int i = 0; i < mesh.indexCount(); i++) { + assertTrue(mesh.index(i) >= 0 && mesh.index(i) < mesh.vertexCount(), shape.name()); + } + for (int i = 0; i < mesh.vertexCount(); i++) { + assertTrue(mesh.gradient(i) >= 0f && mesh.gradient(i) <= 1f, shape.name()); + assertTrue(mesh.shade(i) >= 0f && mesh.shade(i) <= 1f, shape.name()); + } + } + } + + @Test + void starHasFivePointsOnTheUnitCircle() { + ShapeGeometry.build(Shape.STAR, mesh, 8, 0.25f, 0f); + int tips = 0; + for (int i = 0; i < mesh.vertexCount(); i++) { + if (Math.hypot(mesh.x(i), mesh.z(i)) > 0.999) tips++; + } + assertEquals(6, tips, "5 tips plus the duplicated closing vertex"); + assertEquals(30, mesh.indexCount()); + } + + @Test + void hexagonAlternatesMainAndDarkShade() { + ShapeGeometry.build(Shape.HEXAGON, mesh, 8, 0.25f, 0f); + assertEquals(24, mesh.vertexCount()); + assertEquals(0f, mesh.shade(0)); + assertEquals(ShapeGeometry.EDGE_SHADE, mesh.shade(4)); + } + + @Test + void segmentCountScalesDetailAndRebuildClearsPreviousShape() { + ShapeGeometry.build(Shape.PETALS, mesh, 8, 0.25f, 0f); + int coarse = mesh.vertexCount(); + ShapeGeometry.build(Shape.PETALS, mesh, 16, 0.25f, 0f); + assertTrue(mesh.vertexCount() > coarse); + ShapeGeometry.build(Shape.DIAMOND, mesh, 16, 0.25f, 0f); + assertEquals(5, mesh.vertexCount()); + assertEquals(12, mesh.indexCount()); + } + + @Test + void wavePhaseMovesTheRingButKeepsVertexCount() { + ShapeGeometry.build(Shape.WAVE, mesh, 40, 0.2f, 0f); + int count = mesh.vertexCount(); + float x = mesh.x(1); + float z = mesh.z(1); + ShapeGeometry.build(Shape.WAVE, mesh, 40, 0.2f, 1.5f); + assertEquals(count, mesh.vertexCount()); + assertTrue(Math.abs(x - mesh.x(1)) + Math.abs(z - mesh.z(1)) > 1e-4); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/i18n/DescriptionCoverageTest.java b/mod/src/test/java/dev/loki/lovisual/i18n/DescriptionCoverageTest.java new file mode 100644 index 0000000..da9b666 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/i18n/DescriptionCoverageTest.java @@ -0,0 +1,67 @@ +package dev.loki.lovisual.i18n; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Every module and HUD element shown in the GUI must have an en and ru description. */ +class DescriptionCoverageTest { + private static final Path SRC = Path.of("src/main/java"); + private static final Path LANG = Path.of("src/main/resources/assets/lovisual/lang"); + private static final Pattern MODULE = Pattern.compile("@ModuleInfo\\((.*?)\\)\\s*(?:public |final |abstract )*class", Pattern.DOTALL); + private static final Pattern HUD_INFO = Pattern.compile("@HudElementInfo\\((.*?)\\)\\s*(?:public |final |abstract )*class", Pattern.DOTALL); + private static final Pattern ID = Pattern.compile("\\bid\\s*=\\s*\"([^\"]+)\""); + private static final Pattern HUD_SUPER = Pattern.compile("super\\(\"([a-z_0-9]+)\",\\s*\"[^\"]+\",\\s*(?:true|false)"); + private static final List READ_ONLY_HUD_CARDS = List.of("ui_relations_color", "ui_items_color"); + + @Test + void everyModuleAndHudElementHasDescriptions() throws IOException { + TreeSet keys = new TreeSet<>(); + List sources = readSources(); + for (String source : sources) { + collect(MODULE, source, "module.%s.description", keys); + collect(HUD_INFO, source, "hud.%s.description", keys); + Matcher m = HUD_SUPER.matcher(source); + while (m.find()) keys.add("hud." + m.group(1) + ".description"); + } + READ_ONLY_HUD_CARDS.forEach(id -> keys.add("hud." + id + ".description")); + assertTrue(keys.size() > 100, "source scan found suspiciously few ids: " + keys.size()); + + for (String lang : List.of("en_us.json", "ru_ru.json")) { + String json = Files.readString(LANG.resolve(lang)); + List missing = new ArrayList<>(); + for (String key : keys) { + if (!json.contains("\"" + key + "\"")) missing.add(key); + } + assertTrue(missing.isEmpty(), lang + " lacks descriptions: " + missing); + } + } + + private static void collect(Pattern annotation, String source, String keyFormat, TreeSet out) { + Matcher m = annotation.matcher(source); + while (m.find()) { + Matcher id = ID.matcher(m.group(1)); + if (id.find()) out.add(keyFormat.formatted(id.group(1))); + } + } + + private static List readSources() throws IOException { + List out = new ArrayList<>(); + try (Stream files = Files.walk(SRC)) { + for (Path p : (Iterable) files.filter(f -> f.toString().endsWith(".java"))::iterator) { + out.add(Files.readString(p)); + } + } + return out; + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdgeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdgeTest.java new file mode 100644 index 0000000..8547d99 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/renderer/quick/GlassEdgeTest.java @@ -0,0 +1,28 @@ +package dev.loki.lovisual.render.engine.renderer.quick; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class GlassEdgeTest { + @Test + void stopsAreWhiteWithBrightEndsAndDimMiddle() { + int[] c = GlassEdge.stops(1f, 1f); + for (int argb : c) assertEquals(0xFFFFFF, argb & 0xFFFFFF); + assertTrue((c[0] >>> 24) > (c[1] >>> 24)); + assertTrue((c[3] >>> 24) > (c[2] >>> 24)); + } + + @Test + void zeroAlphaIsInvisibleAndInputIsClamped() { + for (int argb : GlassEdge.stops(0f, 1f)) assertEquals(0, argb >>> 24); + int[] hot = GlassEdge.stops(5f, 5f); + int[] max = GlassEdge.stops(1f, 1f); + assertArrayEquals(max, hot); + } + + @Test + void shineRaisesTheHighlights() { + assertTrue((GlassEdge.stops(1f, 1f)[0] >>> 24) > (GlassEdge.stops(1f, 0f)[0] >>> 24)); + } +}